Merge pull request #1 from madhura68/feat/wizard-toast-validation
Add wizard flows, toast feedback, and strict form validation
|
|
@ -1,2 +1,5 @@
|
||||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
|
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
|
||||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key_here
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key_here
|
||||||
|
SUPABASE_SECRET_KEY=sb_secret_your_local_admin_key_here
|
||||||
|
DEMO_USER_PASSWORD=DemoPassword123!
|
||||||
|
NEXT_PUBLIC_ENABLE_TEST_WIZARD=false
|
||||||
|
|
|
||||||
3
.github/workflows/ci.yml
vendored
|
|
@ -31,6 +31,9 @@ jobs:
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
2
.nvmrc
|
|
@ -1 +1 @@
|
||||||
20.9.0
|
20.19.0
|
||||||
|
|
|
||||||
84
CLAUDE.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Inspannings Monitor** — a Dutch-language wellness web app for energy planning, daily check-ins, reflection, and self-evaluation. UI and all user-facing text are in Dutch (nl-NL). Release 1 targets individuals only; no sharing, AI features, or medical claims.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start dev server (localhost:3000)
|
||||||
|
npm run build # Production build
|
||||||
|
npm run lint # ESLint
|
||||||
|
```
|
||||||
|
|
||||||
|
No test framework is configured yet.
|
||||||
|
|
||||||
|
Node version: `20.9.0` (see `.nvmrc`).
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env.local` and fill in your Supabase project values:
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
Supabase project must have email/password auth enabled with email confirmation. Apply migrations from `supabase/migrations/` to your local/remote DB.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Stack:** Next.js (App Router) + React 19 + TypeScript + Supabase (Auth + PostgreSQL) + shadcn/ui + Tailwind CSS. Deployed on Vercel.
|
||||||
|
|
||||||
|
### Route structure
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Public landing page |
|
||||||
|
| `/login`, `/sign-up` | Auth pages |
|
||||||
|
| `/auth/confirm` | Email confirmation callback |
|
||||||
|
| `/onboarding` | Mandatory first-time setup |
|
||||||
|
| `/dashboard` | Main protected page |
|
||||||
|
| `/settings` | User preferences |
|
||||||
|
|
||||||
|
### Auth & data flow
|
||||||
|
|
||||||
|
- `lib/auth/` — `getAuthState()` validates the session server-side from SSR cookies. All protected routes call this and redirect unauthenticated users to `/login`.
|
||||||
|
- New users are redirected to `/onboarding`; dashboard redirects there if onboarding is incomplete.
|
||||||
|
- On first protected page load, `profiles` and `user_settings` rows are auto-created with defaults if missing.
|
||||||
|
- Server Actions (`app/**/actions.ts`) handle form mutations; client components call these directly.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
Two tables with Row Level Security (users see only their own rows):
|
||||||
|
|
||||||
|
- **`profiles`** — display name, locale, timezone, onboarding completion flags
|
||||||
|
- **`user_settings`** — reminder preferences, energy point visibility
|
||||||
|
|
||||||
|
Migrations live in `supabase/migrations/`.
|
||||||
|
|
||||||
|
### Key lib modules
|
||||||
|
|
||||||
|
- `lib/supabase/` — Supabase client setup (server-side SSR client + proxy config)
|
||||||
|
- `lib/auth/` — session helpers, navigation utilities, Dutch error messages
|
||||||
|
- `lib/profile/service.ts` — CRUD for profiles and user_settings
|
||||||
|
- `lib/profile/types.ts` — shared TypeScript types for profile/settings data
|
||||||
|
- `lib/onboarding/` — onboarding options and timezone lists
|
||||||
|
|
||||||
|
### UI components
|
||||||
|
|
||||||
|
`components/ui/` contains shadcn/ui primitives (button, card, input, select, alert, etc.). Feature-level components live in `components/auth/`, `components/onboarding/`, and `components/settings/`. Path alias `@/*` resolves from the repo root.
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
GitHub Actions (`.github/workflows/ci.yml`) runs lint + build on every PR and push to `main`. Vercel auto-deploys previews on branches and production on `main`. Production domain: `inspannings-monitor.jp-visser.nl`.
|
||||||
|
|
||||||
|
## Planned next work
|
||||||
|
|
||||||
|
From the backlog (tracked in Linear):
|
||||||
|
- **ST-201** — Morning check-in feature
|
||||||
|
- **ST-203** — Energy budget logic
|
||||||
|
- **ST-301** — Activities data model
|
||||||
90
README.md
|
|
@ -17,9 +17,22 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal:
|
||||||
|
|
||||||
- e-mail/wachtwoord-auth via Supabase
|
- e-mail/wachtwoord-auth via Supabase
|
||||||
- protected dashboard met server-side sessiecontrole
|
- protected dashboard met server-side sessiecontrole
|
||||||
|
- ochtendcheck-in voor energiescore en slaapkwaliteit van vandaag
|
||||||
|
- eenvoudig dagbudget en energieniveau op basis van de ochtendscore
|
||||||
|
- dashboardweergave van check-instatus, energieniveau en dagbudget
|
||||||
|
- planningsfundering met activiteitenmodel, categorieën en skip-redenen in Supabase
|
||||||
|
- planningpagina voor vandaag met activiteit toevoegen en directe lijstweergave
|
||||||
|
- statusflows voor activiteiten van vandaag (`gepland`, `uitgevoerd`, `overgeslagen`, `aangepast`)
|
||||||
|
- contextuele evaluatievelden voor overgeslagen en aangepaste activiteiten
|
||||||
|
- dagoverzicht op planning met gepland versus werkelijk en statusverdeling
|
||||||
|
- autocomplete op basis van eerdere eigen activiteiten voor sneller hergebruik in planning
|
||||||
|
- energiemeter met lopend totaal ten opzichte van het dagbudget
|
||||||
|
- niet-blokkerende waarschuwing bij budgetoverschrijding in planning en dashboard
|
||||||
|
- eerste unit tests voor budget- en meterlogica via `Vitest`
|
||||||
- korte onboardingflow voor eerste voorkeuren
|
- korte onboardingflow voor eerste voorkeuren
|
||||||
- instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten
|
- instellingen voor profieltekst, avatar, taal, timezone, reminders en zichtbaarheid van energiepunten
|
||||||
- `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen
|
- `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen
|
||||||
|
- `Dusk`-theme met dark-mode prioriteit, semantische oppervlakken en verbeterde focus-/toegankelijkheidsstijlen
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
|
@ -37,6 +50,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal:
|
||||||
2. Vul in:
|
2. Vul in:
|
||||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||||
- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`
|
- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`
|
||||||
|
- optioneel: `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` voor de interne wizard-testpagina
|
||||||
3. Installeer dependencies met `npm install`
|
3. Installeer dependencies met `npm install`
|
||||||
4. Start lokaal met `npm run dev`
|
4. Start lokaal met `npm run dev`
|
||||||
|
|
||||||
|
|
@ -46,6 +60,8 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal:
|
||||||
- `npm run build`
|
- `npm run build`
|
||||||
- `npm run start`
|
- `npm run start`
|
||||||
- `npm run lint`
|
- `npm run lint`
|
||||||
|
- `npm run test`
|
||||||
|
- `npm run seed:demo-users`
|
||||||
|
|
||||||
## Supabase Auth configuratie
|
## Supabase Auth configuratie
|
||||||
|
|
||||||
|
|
@ -65,34 +81,92 @@ Gebruik alleen `.env.example` als template. Lokale bestanden zoals `.env` en
|
||||||
|
|
||||||
## Supabase database migraties
|
## Supabase database migraties
|
||||||
|
|
||||||
Voor `ST-102` staat de eerste databasefundering in:
|
De huidige app gebruikt onder meer deze migraties:
|
||||||
|
|
||||||
- `supabase/migrations/20260418_create_profiles_and_user_settings.sql`
|
- `supabase/migrations/20260418_create_profiles_and_user_settings.sql`
|
||||||
|
- `supabase/migrations/20260418_add_onboarding_seen_to_profiles.sql`
|
||||||
|
- `supabase/migrations/20260418_create_morning_check_ins.sql`
|
||||||
|
- `supabase/migrations/20260418_add_budget_fields_to_morning_check_ins.sql`
|
||||||
|
- `supabase/migrations/20260419_create_activities_and_reference_data.sql`
|
||||||
|
- `supabase/migrations/20260419_add_profile_details_and_avatar_storage.sql`
|
||||||
|
|
||||||
Voer deze SQL uit in de Supabase SQL Editor of via de Supabase CLI voordat je
|
Voer deze SQL uit in de Supabase SQL Editor of via de Supabase CLI voordat je
|
||||||
de profile/settings-laag lokaal test.
|
de profile-, check-in- en budgetlagen lokaal test.
|
||||||
|
|
||||||
|
## Demo-gebruikers seeden
|
||||||
|
|
||||||
|
Er staat nu een seedscript klaar voor fictieve demo-gebruikers op basis van de
|
||||||
|
persona-set in [inspannings-monitor-08-gebruikerspersonas-v01.docx](/Users/janpetervisser/Development/third/docs/inspannings-monitor-08-gebruikerspersonas-v01.docx).
|
||||||
|
|
||||||
|
Benodigd:
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||||
|
- `SUPABASE_SECRET_KEY` voorkeur
|
||||||
|
- `SUPABASE_SERVICE_ROLE_KEY` mag nog als legacy alias
|
||||||
|
- `DEMO_USER_PASSWORD`
|
||||||
|
|
||||||
|
Uitvoeren:
|
||||||
|
|
||||||
|
1. zorg dat de profiel- en storage-migraties al in Supabase zijn uitgevoerd
|
||||||
|
2. zet de drie env-vars lokaal
|
||||||
|
3. run `npm run seed:demo-users`
|
||||||
|
|
||||||
|
Voor bestaande lokale setups accepteert het script tijdelijk ook:
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_SERVICE_KEY`
|
||||||
|
|
||||||
|
Maar mijn advies is om voor seedscripts alleen deze nette niet-public adminnaam te gebruiken:
|
||||||
|
- `SUPABASE_SECRET_KEY`
|
||||||
|
|
||||||
|
De seeddata zelf staat in:
|
||||||
|
- [demo-personas.mjs](/Users/janpetervisser/Development/third/scripts/seed/demo-personas.mjs)
|
||||||
|
|
||||||
|
Een dry run kan ook:
|
||||||
|
- `npm run seed:demo-users -- --dry-run`
|
||||||
|
|
||||||
## UI foundation
|
## UI foundation
|
||||||
|
|
||||||
De app gebruikt `shadcn/ui` bovenop `Tailwind CSS` als herbruikbare basis voor
|
De app gebruikt `shadcn/ui` bovenop `Tailwind CSS` als herbruikbare basis voor
|
||||||
knoppen, formulieren, kaarten en meldingen. De theme tokens staan centraal in
|
knoppen, formulieren, kaarten en meldingen. De theme tokens staan centraal in
|
||||||
`app/globals.css`, zodat kleur, focus-states en componentgedrag consistenter blijven.
|
`app/globals.css`, zodat kleur, focus-states en componentgedrag consistenter blijven.
|
||||||
|
Voor feedback na redirects of server actions krijgt de app nu standaard de voorkeur
|
||||||
|
voor `sonner`-toasts boven losse inline statusmeldingen.
|
||||||
|
|
||||||
|
De actuele visuele richting is `Dusk`: warme paper-achtergronden, gedempte indigo
|
||||||
|
als primaire kleur, dark mode als standaard en semantische `success`/`warning`
|
||||||
|
tokens voor rustige, niet-medische feedback.
|
||||||
|
|
||||||
|
## Navigatie
|
||||||
|
|
||||||
|
De app gebruikt nu een gedeelde topnavigatie:
|
||||||
|
|
||||||
|
- links: `About`, `Dashboard`, `Planning`, `Check-in`
|
||||||
|
- rechts: `Account` en `Theme`
|
||||||
|
|
||||||
|
`/` is de publieke `About`-pagina met informatie over de maker en de scope van
|
||||||
|
de app. In het `Account`-menu komen ingelogde gebruikers bij `Instellingen` en
|
||||||
|
`Uitloggen`; uitgelogde gebruikers zien daar `Inloggen` en `Account aanmaken`.
|
||||||
|
|
||||||
|
## Interne wizard-test
|
||||||
|
|
||||||
|
Er is een interne testwizard beschikbaar op `/wizard-test` om een toekomstige
|
||||||
|
generieke wizard-core te valideren. Deze route en de dashboardknop worden alleen
|
||||||
|
zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat.
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
- `CI`: GitHub Actions draait automatisch `lint` en `build` op pull requests en op `main`
|
- `CI`: GitHub Actions draait automatisch `lint`, `test` en `build` op pull requests en op `main`
|
||||||
- `CD`: Vercel deployt automatisch previews voor branches/PR's en productie vanaf `main`
|
- `CD`: Vercel deployt automatisch previews voor branches/PR's en productie vanaf `main`
|
||||||
- Uitwerking: [docs/inspannings-monitor-cicd-en-deploy.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-cicd-en-deploy.md)
|
- Uitwerking: [docs/inspannings-monitor-cicd-en-deploy.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-cicd-en-deploy.md)
|
||||||
|
|
||||||
## Documentatie
|
## Documentatie
|
||||||
|
|
||||||
- Hoofdset specificaties en plannen: [docs/README.md](/Users/janpetervisser/Development/third/docs/README.md)
|
- Hoofdset specificaties en plannen: [docs/README.md](/Users/janpetervisser/Development/third/docs/README.md)
|
||||||
|
- Dusk theme-specificatie: [inspannings-monitor-09-dusk-theme-specificatie-v01.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-09-dusk-theme-specificatie-v01.md)
|
||||||
- Technische architectuur: [inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx](/Users/janpetervisser/Development/third/docs/inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx)
|
- Technische architectuur: [inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx](/Users/janpetervisser/Development/third/docs/inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx)
|
||||||
- Implementatieplan en backlog: [inspannings-monitor-06-implementatieplan-en-backlog-v01.docx](/Users/janpetervisser/Development/third/docs/inspannings-monitor-06-implementatieplan-en-backlog-v01.docx)
|
- Implementatieplan en backlog: [inspannings-monitor-06-implementatieplan-en-backlog-v01.docx](/Users/janpetervisser/Development/third/docs/inspannings-monitor-06-implementatieplan-en-backlog-v01.docx)
|
||||||
|
|
||||||
## Eerstvolgende bouwstappen
|
## Eerstvolgende bouwstappen
|
||||||
|
|
||||||
1. `ST-201` Ochtendcheck-in UI bouwen
|
1. `ST-105` RLS-policy tests en hardening afronden
|
||||||
2. `ST-203` Budgetlogica implementeren
|
2. logging en monitoring toevoegen
|
||||||
3. `ST-301` Activiteitenmodel en planning opzetten
|
3. rate limiting op kritieke mutaties
|
||||||
4. `ST-105` RLS-policy tests en hardening afronden
|
|
||||||
|
|
|
||||||
305
aanbeveling-claude.md
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
# Actuele prioriteiten voor Inspannings Monitor
|
||||||
|
|
||||||
|
Peildatum: **19 april 2026** — bijgewerkt op basis van de huidige codebase en de
|
||||||
|
recente implementaties voor check-in, planning, energiemeter, Dusk-thema en
|
||||||
|
navigatiestructuur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Samenvatting
|
||||||
|
|
||||||
|
De app heeft inmiddels een **sterk fundament**:
|
||||||
|
|
||||||
|
- auth en protected routes werken
|
||||||
|
- onboarding en instellingen zijn aanwezig
|
||||||
|
- ochtendcheck-in en budgetlogica zijn aanwezig
|
||||||
|
- planning en energiemeter zijn aanwezig
|
||||||
|
- error/loading routes bestaan voor de belangrijkste dataroutes
|
||||||
|
- pending states en toastfeedback zijn aanwezig
|
||||||
|
- CI/CD, branch protection en Vercel-deploy staan
|
||||||
|
|
||||||
|
De grootste winst zit nu niet meer in fundering, maar in:
|
||||||
|
|
||||||
|
1. **de plan-do-evalueer-lus sluiten**
|
||||||
|
2. **kritieke technische gaten dichten vóór launch**
|
||||||
|
3. **test- en securitylaag versterken**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wat al op orde is
|
||||||
|
|
||||||
|
Deze punten stonden eerder als aanbeveling open, maar zijn inmiddels al
|
||||||
|
afgerond of grotendeels opgelost:
|
||||||
|
|
||||||
|
- `error.tsx` voor de belangrijkste app-routes
|
||||||
|
- `loading.tsx` voor dashboard, check-in en planning
|
||||||
|
- pending states in onboarding, settings, check-in en planning
|
||||||
|
- centrale toastlaag voor redirect- en action-feedback
|
||||||
|
- expliciete `FormData`-validatie
|
||||||
|
- wizard-core en onboarding-refactor
|
||||||
|
- ochtendcheck-in en budget v1
|
||||||
|
- planning, energiemeter en niet-blokkerende budgetwaarschuwing
|
||||||
|
- Dusk-themafundering en toegankelijkheidspolish
|
||||||
|
- topnavigatie met publieke About-pagina
|
||||||
|
|
||||||
|
Deze punten hoeven dus **niet** opnieuw als directe actielijst te worden gezien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nu doen
|
||||||
|
|
||||||
|
Dit zijn de hoogste actuele prioriteiten voor de eerstvolgende sprint.
|
||||||
|
|
||||||
|
### 1. ST-401 t/m ST-405 — Evaluatie en dagoverzicht
|
||||||
|
|
||||||
|
De app ondersteunt nu:
|
||||||
|
|
||||||
|
- check-in
|
||||||
|
- plannen
|
||||||
|
- energiebudget
|
||||||
|
|
||||||
|
Maar de daglus is nog niet af zolang de gebruiker activiteiten niet kan:
|
||||||
|
|
||||||
|
- afronden
|
||||||
|
- overslaan
|
||||||
|
- aanpassen
|
||||||
|
- samenvatten in een dagoverzicht
|
||||||
|
|
||||||
|
**Waarom nu:** dit is de grootste productmatige ontbrekende schakel. De app
|
||||||
|
voelt nu al nuttig, maar nog niet “rond”.
|
||||||
|
|
||||||
|
**Concreet:**
|
||||||
|
|
||||||
|
- activiteitstatus wijzigen naar `completed`, `skipped`, `adjusted`
|
||||||
|
- ongeplande activiteit kunnen toevoegen
|
||||||
|
- dagaggregaties berekenen
|
||||||
|
- dagoverzicht tonen met totalen en statusverdeling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `npm test` toevoegen aan CI
|
||||||
|
|
||||||
|
De app heeft nu unit tests voor:
|
||||||
|
|
||||||
|
- budgetlogica
|
||||||
|
- energiemeterlogica
|
||||||
|
|
||||||
|
Maar in CI draaien nog alleen:
|
||||||
|
|
||||||
|
- `lint`
|
||||||
|
- `build`
|
||||||
|
|
||||||
|
**Waarom nu:** dit is een kleine wijziging met directe kwaliteitswinst.
|
||||||
|
|
||||||
|
**Concreet:**
|
||||||
|
|
||||||
|
- voeg `npm run test` toe aan `.github/workflows/ci.yml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Tijdzonehelper dedupliceren
|
||||||
|
|
||||||
|
`getLocalDateForTimezone()` staat nu nog dubbel in:
|
||||||
|
|
||||||
|
- `lib/check-in/service.ts`
|
||||||
|
- `lib/planning/service.ts`
|
||||||
|
|
||||||
|
**Waarom nu:** klein, veilig en voorkomt toekomstige divergentie.
|
||||||
|
|
||||||
|
**Concreet:**
|
||||||
|
|
||||||
|
- verplaats naar `lib/dates.ts`
|
||||||
|
- importeer vanuit beide services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Onverwachte DB-fouten consistenter afvangen in server actions
|
||||||
|
|
||||||
|
Validatiefouten worden al goed afgehandeld. Wat nog niet overal strak genoeg is:
|
||||||
|
|
||||||
|
- onverwachte Supabase/DB-fouten
|
||||||
|
- partiële storingen in servicecalls
|
||||||
|
|
||||||
|
Nu eindigen sommige fouten nog als generieke exception, terwijl de gebruiker
|
||||||
|
beter een nette foutcode/toast kan krijgen.
|
||||||
|
|
||||||
|
**Waarom nu:** dit verhoogt herstelbaarheid zonder grote refactor.
|
||||||
|
|
||||||
|
**Concreet:**
|
||||||
|
|
||||||
|
- alle action-bestanden nalopen
|
||||||
|
- onverwachte servicefouten mappen naar gebruikersvriendelijke foutcodes
|
||||||
|
- bestaande `status/error`-toastpatronen hergebruiken
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daarna doen
|
||||||
|
|
||||||
|
Deze punten zijn belangrijk, maar komen logisch ná de evaluatiefase.
|
||||||
|
|
||||||
|
### 5. ST-105 — RLS hardening en echte policy-tests
|
||||||
|
|
||||||
|
RLS staat aan en ziet er inhoudelijk goed uit, maar is nog niet systematisch
|
||||||
|
getest tegen misbruikscenario’s.
|
||||||
|
|
||||||
|
**Concreet:**
|
||||||
|
|
||||||
|
- SQL-tests of handmatige scripts schrijven
|
||||||
|
- lezen/schrijven van andermans rijen expliciet proberen
|
||||||
|
- checken dat frontend/Vercel geen admin-secret gebruikt
|
||||||
|
|
||||||
|
**Waarom daarna:** belangrijk vóór launch, maar blokkeert de volgende productstap
|
||||||
|
niet direct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Testdekking uitbreiden rond pure logica
|
||||||
|
|
||||||
|
Na de bestaande budget- en meter-tests zijn dit de beste vervolgstukken:
|
||||||
|
|
||||||
|
- `lib/forms/parse.ts`
|
||||||
|
- toekomstige dagaggregatie voor evaluatie
|
||||||
|
- tijdzone/datumhelpers zodra die gedeeld zijn
|
||||||
|
|
||||||
|
**Waarom daarna:** klein en waardevol, maar minder productkritisch dan ST-401.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Transactie of RPC voor onboarding-opslag
|
||||||
|
|
||||||
|
`completeOnboardingForCurrentUser()` doet nu twee losse updates:
|
||||||
|
|
||||||
|
- `profiles`
|
||||||
|
- `user_settings`
|
||||||
|
|
||||||
|
Dat werkt, maar kent een klein risico op partiële opslag als de tweede write
|
||||||
|
faalt.
|
||||||
|
|
||||||
|
**Concreet:**
|
||||||
|
|
||||||
|
- ofwel Supabase RPC
|
||||||
|
- ofwel server-side transactiepad waar haalbaar
|
||||||
|
|
||||||
|
**Waarom daarna:** belangrijk voor netheid en robuustheid, maar geen acute
|
||||||
|
blokkade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vóór launch
|
||||||
|
|
||||||
|
Deze punten hoeven niet allemaal in de volgende sprint, maar moeten wel vóór een
|
||||||
|
serieuze publieke introductie op orde zijn.
|
||||||
|
|
||||||
|
### 8. Logging en monitoring
|
||||||
|
|
||||||
|
Nu ontbreekt nog een echte productieloglaag zoals:
|
||||||
|
|
||||||
|
- Sentry
|
||||||
|
- of vergelijkbare error monitoring
|
||||||
|
|
||||||
|
**Nodig voor:**
|
||||||
|
|
||||||
|
- incidenten terugvinden
|
||||||
|
- onverwachte action-/DB-fouten volgen
|
||||||
|
- regressies sneller herkennen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Rate limiting
|
||||||
|
|
||||||
|
Nu leunt auth vooral op Supabase-limieten. Voor de app zelf ontbreekt nog
|
||||||
|
bewuste begrenzing op mutaties zoals:
|
||||||
|
|
||||||
|
- check-in opslaan
|
||||||
|
- activiteit toevoegen
|
||||||
|
- latere evaluatie-updates
|
||||||
|
|
||||||
|
**Doel:** misbruik, spam en piekgedrag beperken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Secret-history cleanup
|
||||||
|
|
||||||
|
Een oude Supabase `service_role` key heeft eerder in de git-history gestaan.
|
||||||
|
|
||||||
|
Ook al is die key niet meer actief in de app, vóór publieke launch is het nog
|
||||||
|
steeds verstandig om:
|
||||||
|
|
||||||
|
- die geschiedenis op te schonen
|
||||||
|
- en te bevestigen dat de sleutel niet meer bruikbaar is
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Accessibility, copy en privacy-review
|
||||||
|
|
||||||
|
Voor launch nog nalopen:
|
||||||
|
|
||||||
|
- toetsenbord- en screenreaderflow op kritieke routes
|
||||||
|
- wellness/self-management copy zonder medische framing
|
||||||
|
- privacy/DPIA-check passend bij jullie positionering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Later
|
||||||
|
|
||||||
|
Deze punten zijn nuttig, maar nu nog niet de beste besteding van tijd.
|
||||||
|
|
||||||
|
### 12. Paginering en schaaloptimalisaties
|
||||||
|
|
||||||
|
Bij de huidige MVP is de activiteitslijst nog klein. Zaken als:
|
||||||
|
|
||||||
|
- paginering
|
||||||
|
- geavanceerde caching
|
||||||
|
- query-optimisaties voor grote datasets
|
||||||
|
|
||||||
|
zijn voorlopig geen topprioriteit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. Supabase codegeneratie voor types
|
||||||
|
|
||||||
|
De huidige handmatige mapping is nog beheersbaar. Codegeneratie via Supabase CLI
|
||||||
|
kan later waardevol worden, maar nu voegt het waarschijnlijk meer toolinglast
|
||||||
|
toe dan directe winst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. Zwaardere compliance-laag
|
||||||
|
|
||||||
|
Formele zorgcompliance, audittrail en NEN-achtige eisen zijn pas logisch als de
|
||||||
|
productpositionering echt opschuift richting zorgmarkt. Voor de huidige
|
||||||
|
wellness-first MVP is dat nog niet de eerstvolgende stap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aangescherpte prioriteitsvolgorde
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nu: ST-401–405 (evaluatie en dagoverzicht)
|
||||||
|
Nu: npm test toevoegen aan CI
|
||||||
|
Nu: getLocalDateForTimezone() dedupliceren naar lib/dates.ts
|
||||||
|
Nu: onverwachte DB-fouten in actions consistenter mappen
|
||||||
|
Daarna: ST-105 (RLS hardening en policy-tests)
|
||||||
|
Daarna: extra tests voor parse- en aggregatielogica
|
||||||
|
Daarna: onboarding-opslag transactioneler maken
|
||||||
|
Vóór launch: logging/monitoring
|
||||||
|
Vóór launch: rate limiting
|
||||||
|
Vóór launch: secret-history cleanup
|
||||||
|
Vóór launch: accessibility/copy/privacy review
|
||||||
|
Later: paginering, codegen, zwaardere compliance-laag
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Korte conclusie
|
||||||
|
|
||||||
|
De app is niet meer in de fase van “fundering ontbreekt”. Die fase is grotendeels
|
||||||
|
voorbij. De actuele focus moet nu verschuiven naar:
|
||||||
|
|
||||||
|
- de gebruikerslus afmaken
|
||||||
|
- reliability aanscherpen
|
||||||
|
- en launch-risico’s gecontroleerd terugdringen
|
||||||
|
|
||||||
|
De beste volgende inhoudelijke stap is daarom nog steeds:
|
||||||
|
|
||||||
|
**`ST-401 t/m ST-405` — evaluatie en dagoverzicht.**
|
||||||
BIN
app/apple-icon.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
|
|
@ -7,28 +7,76 @@ import {
|
||||||
getRequestOrigin,
|
getRequestOrigin,
|
||||||
sanitizeNextPath,
|
sanitizeNextPath,
|
||||||
} from "@/lib/auth/navigation";
|
} from "@/lib/auth/navigation";
|
||||||
|
import {
|
||||||
|
assertEmail,
|
||||||
|
assertMinLength,
|
||||||
|
FormDataValidationError,
|
||||||
|
getOptionalString,
|
||||||
|
getRequiredString,
|
||||||
|
} from "@/lib/forms/parse";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { hasSupabaseEnv } from "@/lib/supabase/config";
|
import { hasSupabaseEnv } from "@/lib/supabase/config";
|
||||||
|
|
||||||
function getString(formData: FormData, key: string) {
|
function parseSignInFormData(formData: FormData) {
|
||||||
const value = formData.get(key);
|
const next = sanitizeNextPath(getOptionalString(formData, "next"));
|
||||||
return typeof value === "string" ? value.trim() : "";
|
const email = assertEmail(
|
||||||
|
getRequiredString(formData, "email", "missing-fields"),
|
||||||
|
"invalid-email",
|
||||||
|
);
|
||||||
|
const password = assertMinLength(
|
||||||
|
getRequiredString(formData, "password", "missing-fields"),
|
||||||
|
8,
|
||||||
|
"password-too-short",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
next,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSignUpFormData(formData: FormData) {
|
||||||
|
const next = sanitizeNextPath(getOptionalString(formData, "next"));
|
||||||
|
const email = assertEmail(
|
||||||
|
getRequiredString(formData, "email", "missing-fields"),
|
||||||
|
"invalid-email",
|
||||||
|
);
|
||||||
|
const password = assertMinLength(
|
||||||
|
getRequiredString(formData, "password", "missing-fields"),
|
||||||
|
8,
|
||||||
|
"password-too-short",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
next,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signInAction(formData: FormData) {
|
export async function signInAction(formData: FormData) {
|
||||||
const next = sanitizeNextPath(getString(formData, "next"));
|
let next = sanitizeNextPath(getOptionalString(formData, "next"));
|
||||||
|
let email = "";
|
||||||
|
let password = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedFormData = parseSignInFormData(formData);
|
||||||
|
next = parsedFormData.next;
|
||||||
|
email = parsedFormData.email;
|
||||||
|
password = parsedFormData.password;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormDataValidationError) {
|
||||||
|
redirect(buildPathWithQuery("/login", { error: error.code, next }));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasSupabaseEnv()) {
|
if (!hasSupabaseEnv()) {
|
||||||
redirect(buildPathWithQuery("/login", { error: "auth-not-configured", next }));
|
redirect(buildPathWithQuery("/login", { error: "auth-not-configured", next }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = getString(formData, "email");
|
|
||||||
const password = getString(formData, "password");
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
redirect(buildPathWithQuery("/login", { error: "missing-fields", next }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
|
|
@ -50,7 +98,22 @@ export async function signInAction(formData: FormData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signUpAction(formData: FormData) {
|
export async function signUpAction(formData: FormData) {
|
||||||
const next = sanitizeNextPath(getString(formData, "next"));
|
let next = sanitizeNextPath(getOptionalString(formData, "next"));
|
||||||
|
let email = "";
|
||||||
|
let password = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedFormData = parseSignUpFormData(formData);
|
||||||
|
next = parsedFormData.next;
|
||||||
|
email = parsedFormData.email;
|
||||||
|
password = parsedFormData.password;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormDataValidationError) {
|
||||||
|
redirect(buildPathWithQuery("/sign-up", { error: error.code, next }));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasSupabaseEnv()) {
|
if (!hasSupabaseEnv()) {
|
||||||
redirect(
|
redirect(
|
||||||
|
|
@ -58,13 +121,6 @@ export async function signUpAction(formData: FormData) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = getString(formData, "email");
|
|
||||||
const password = getString(formData, "password");
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
redirect(buildPathWithQuery("/sign-up", { error: "missing-fields", next }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const headerStore = await headers();
|
const headerStore = await headers();
|
||||||
const origin = getRequestOrigin(headerStore);
|
const origin = getRequestOrigin(headerStore);
|
||||||
|
|
|
||||||
47
app/check-in/actions.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { buildPathWithQuery } from "@/lib/auth/navigation";
|
||||||
|
import { SLEEP_QUALITY_VALUES } from "@/lib/check-in/options";
|
||||||
|
import { upsertTodayCheckInForCurrentUser } from "@/lib/check-in/service";
|
||||||
|
import type { MorningCheckInSubmission } from "@/lib/check-in/types";
|
||||||
|
import {
|
||||||
|
FormDataValidationError,
|
||||||
|
getEnumValue,
|
||||||
|
getIntegerValue,
|
||||||
|
} from "@/lib/forms/parse";
|
||||||
|
|
||||||
|
function buildMorningCheckInSubmission(formData: FormData): MorningCheckInSubmission {
|
||||||
|
return {
|
||||||
|
energyScore: getIntegerValue(
|
||||||
|
formData,
|
||||||
|
"energyScore",
|
||||||
|
{ min: 1, max: 10 },
|
||||||
|
"invalid-check-in-input",
|
||||||
|
),
|
||||||
|
sleepQuality: getEnumValue(
|
||||||
|
formData,
|
||||||
|
"sleepQuality",
|
||||||
|
SLEEP_QUALITY_VALUES,
|
||||||
|
"invalid-check-in-input",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMorningCheckInAction(
|
||||||
|
_previousState: null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<null> {
|
||||||
|
try {
|
||||||
|
await upsertTodayCheckInForCurrentUser(buildMorningCheckInSubmission(formData));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormDataValidationError) {
|
||||||
|
redirect(buildPathWithQuery("/check-in", { error: error.code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/dashboard", { status: "check-in-saved" }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
27
app/check-in/error.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"use client";
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function CheckInError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-svh items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-md space-y-4">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Er is iets misgegaan</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
De check-in pagina kon niet worden geladen. Probeer het opnieuw of
|
||||||
|
kom later terug.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button onClick={reset} variant="outline" className="w-full">
|
||||||
|
Opnieuw proberen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
app/check-in/loading.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function CheckInLoading() {
|
||||||
|
return (
|
||||||
|
<main className="app-page">
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8 px-4 sm:px-6">
|
||||||
|
<div className="flex h-16 shrink-0 items-center justify-between border-b border-border/50">
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-7 w-7 rounded-full" />
|
||||||
|
<Skeleton className="h-7 w-7 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
<Skeleton className="h-7 w-56" />
|
||||||
|
<Skeleton className="h-4 w-80 max-w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardContent className="space-y-5 pb-6 pt-4">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-10 flex-1" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
<Skeleton className="mt-1 h-5 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<Skeleton className="mt-2 h-4 w-full" />
|
||||||
|
<Skeleton className="mt-1.5 h-4 w-3/4" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 pb-6">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-5/6" />
|
||||||
|
<Skeleton className="h-4 w-4/6" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
app/check-in/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||||
|
import { AppShell } from "@/components/navigation/app-shell";
|
||||||
|
import { PageIntro } from "@/components/navigation/page-intro";
|
||||||
|
import { CheckInForm } from "@/components/check-in/check-in-form";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { formatEnergyLevelLabel } from "@/lib/check-in/budget";
|
||||||
|
import { getTodayCheckInForCurrentUser } from "@/lib/check-in/service";
|
||||||
|
import { getCheckInStatusToast } from "@/lib/feedback/status-messages";
|
||||||
|
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||||
|
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type CheckInPageProps = {
|
||||||
|
searchParams: Promise<PageSearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CheckInPage({ searchParams }: CheckInPageProps) {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
|
||||||
|
if (!authState.isConfigured) {
|
||||||
|
redirect("/login?error=auth-not-configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/check-in"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileBundle = await getProfileBundleForCurrentUser();
|
||||||
|
|
||||||
|
if (!profileBundle) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/check-in"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profileBundle.profile.onboardingSeen) {
|
||||||
|
redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkInStatus = await getTodayCheckInForCurrentUser();
|
||||||
|
const statusToast = getCheckInStatusToast(
|
||||||
|
getParamValue(resolvedSearchParams, "error"),
|
||||||
|
getParamValue(resolvedSearchParams, "status"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell contentClassName="space-y-8">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||||
|
|
||||||
|
<PageIntro
|
||||||
|
eyebrow="Check-in"
|
||||||
|
title="Ochtendcheck-in van vandaag"
|
||||||
|
description="Houd je start rustig en klein. Je legt alleen een energiescore en een globale slaapindruk vast voor vandaag."
|
||||||
|
aside={
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Terug naar dashboard
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<CheckInForm todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
|
||||||
|
|
||||||
|
<aside className="space-y-5">
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Vandaag
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-lg text-foreground">
|
||||||
|
{checkInStatus?.todayCheckIn ? "Check-in staat al klaar" : "Nog geen check-in"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Lokale datum: {checkInStatus?.todayDate ?? "Onbekend"} in timezone{" "}
|
||||||
|
`{profileBundle.profile.timezone}`.
|
||||||
|
</CardDescription>
|
||||||
|
{checkInStatus?.todayCheckIn ? (
|
||||||
|
<CardDescription className="mt-3 text-sm leading-7 text-muted-foreground">
|
||||||
|
Laatste resultaat: niveau{" "}
|
||||||
|
{formatEnergyLevelLabel(checkInStatus.todayCheckIn.energyLevel).toLowerCase()} met een budget van{" "}
|
||||||
|
{checkInStatus.todayCheckIn.dailyBudget} punten.
|
||||||
|
</CardDescription>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card tone="primary" elevation="raised" className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
||||||
|
Bewuste grens
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 pb-6 text-sm leading-7 text-primary-foreground/90">
|
||||||
|
<p>Deze check-in geeft geen diagnose of medische interpretatie.</p>
|
||||||
|
<p>Je legt alleen een rustige momentopname van vandaag vast.</p>
|
||||||
|
<p>Budget v1 blijft bewust eenvoudig: het dagbudget volgt direct uit je energiescore.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/dashboard/error.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"use client";
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function DashboardError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-svh items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-md space-y-4">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Er is iets misgegaan</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Het dashboard kon niet worden geladen. Probeer het opnieuw of kom
|
||||||
|
later terug.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button onClick={reset} variant="outline" className="w-full">
|
||||||
|
Opnieuw proberen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
app/dashboard/loading.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function DashboardLoading() {
|
||||||
|
return (
|
||||||
|
<main className="app-page">
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8 px-4 sm:px-6">
|
||||||
|
<div className="flex h-16 shrink-0 items-center justify-between border-b border-border/50">
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-7 w-7 rounded-full" />
|
||||||
|
<Skeleton className="h-7 w-7 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
<Skeleton className="h-7 w-64" />
|
||||||
|
<Skeleton className="h-4 w-96 max-w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Card key={i} className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<Skeleton className="h-3 w-14" />
|
||||||
|
<Skeleton className="mt-1 h-5 w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<Skeleton className="mt-2 h-4 w-full" />
|
||||||
|
<Skeleton className="mt-1.5 h-4 w-3/4" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { signOutAction } from "@/app/auth-actions";
|
import { CheckInCard } from "@/components/check-in/check-in-card";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { AppShell } from "@/components/navigation/app-shell";
|
||||||
|
import { PageIntro } from "@/components/navigation/page-intro";
|
||||||
|
import { EnergyMeterCard } from "@/components/planning/energy-meter-card";
|
||||||
|
import { ProfileAvatar } from "@/components/profile/profile-avatar";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -12,23 +15,20 @@ import {
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
import { getAuthState } from "@/lib/auth/session";
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { getTodayCheckInForCurrentUser } from "@/lib/check-in/service";
|
||||||
|
import { isTestWizardEnabled } from "@/lib/config/feature-flags";
|
||||||
|
import { getDashboardStatusToast } from "@/lib/feedback/status-messages";
|
||||||
|
import { getTodayActivitiesForCurrentUser } from "@/lib/planning/service";
|
||||||
|
import { calculatePlanningMeterSnapshot } from "@/lib/planning/meter";
|
||||||
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||||
import { cn } from "@/lib/utils";
|
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type DashboardPageProps = {
|
type DashboardPageProps = {
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<PageSearchParams>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getParamValue(
|
|
||||||
params: Record<string, string | string[] | undefined>,
|
|
||||||
key: string,
|
|
||||||
) {
|
|
||||||
const value = params[key];
|
|
||||||
return typeof value === "string" ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatToggleState(value: boolean, enabledLabel = "Aan", disabledLabel = "Uit") {
|
function formatToggleState(value: boolean, enabledLabel = "Aan", disabledLabel = "Uit") {
|
||||||
return value ? enabledLabel : disabledLabel;
|
return value ? enabledLabel : disabledLabel;
|
||||||
}
|
}
|
||||||
|
|
@ -37,18 +37,6 @@ function formatReminderTime(value: string | null) {
|
||||||
return value ? value.slice(0, 5) : "Nog niet ingesteld";
|
return value ? value.slice(0, 5) : "Nog niet ingesteld";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDashboardNotice(status: string | null) {
|
|
||||||
if (status === "onboarding-completed") {
|
|
||||||
return "Je onboarding is opgeslagen. Je basisinstellingen staan nu klaar.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "onboarding-skipped") {
|
|
||||||
return "Je hebt de onboarding nu overgeslagen. Je kunt hem later alsnog afronden.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function DashboardPage({ searchParams }: DashboardPageProps) {
|
export default async function DashboardPage({ searchParams }: DashboardPageProps) {
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
|
|
@ -68,7 +56,11 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
}
|
}
|
||||||
|
|
||||||
const { profile, settings } = profileBundle;
|
const { profile, settings } = profileBundle;
|
||||||
const notice = getDashboardNotice(getParamValue(resolvedSearchParams, "status"));
|
const [checkInStatus, planningStatus] = await Promise.all([
|
||||||
|
getTodayCheckInForCurrentUser(),
|
||||||
|
getTodayActivitiesForCurrentUser(),
|
||||||
|
]);
|
||||||
|
const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status"));
|
||||||
|
|
||||||
if (!profile.onboardingSeen) {
|
if (!profile.onboardingSeen) {
|
||||||
redirect("/onboarding");
|
redirect("/onboarding");
|
||||||
|
|
@ -79,58 +71,39 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
const morningReminderState = settings.morningReminderEnabled
|
const morningReminderState = settings.morningReminderEnabled
|
||||||
? `Aan om ${formatReminderTime(settings.morningReminderTime)}`
|
? `Aan om ${formatReminderTime(settings.morningReminderTime)}`
|
||||||
: "Uit";
|
: "Uit";
|
||||||
|
const planningMeter = calculatePlanningMeterSnapshot(
|
||||||
|
planningStatus?.activities ?? [],
|
||||||
|
checkInStatus?.todayCheckIn?.dailyBudget ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
|
<AppShell contentClassName="space-y-8">
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
<div className="space-y-8">
|
||||||
{notice ? (
|
<StatusToastBridge toast={statusToast} />
|
||||||
<Alert className="rounded-[1.5rem] border-emerald-200 bg-emerald-50 text-emerald-950 [&_svg]:text-emerald-700">
|
|
||||||
<AlertDescription className="leading-7 text-current">
|
|
||||||
{notice}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<header className="flex flex-col gap-5 rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:flex-row sm:items-start sm:justify-between sm:p-8">
|
<PageIntro
|
||||||
<div>
|
eyebrow="Dashboard"
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
title="Je huidige dagstatus"
|
||||||
Protected route
|
description="Hier zie je in één overzicht je profielbasis, ochtendcheck-in, planningstatus en huidige energiemeter voor vandaag."
|
||||||
</p>
|
aside={
|
||||||
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
|
isTestWizardEnabled() ? (
|
||||||
Dashboard placeholder voor release 1
|
|
||||||
</h1>
|
|
||||||
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-700">
|
|
||||||
Je sessie is server-side gevalideerd en het minimale profielbundle is
|
|
||||||
nu beschikbaar. Daarmee staat de fundering voor onboarding, settings
|
|
||||||
en de eerste energieflows klaar.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form action={signOutAction}>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/wizard-test"
|
||||||
className={cn(
|
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||||
buttonVariants({ variant: "outline", size: "lg" }),
|
|
||||||
"h-11 rounded-full px-5",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Instellingen
|
Test wizard
|
||||||
</Link>
|
</Link>
|
||||||
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
|
) : null
|
||||||
Uitloggen
|
}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="grid gap-5 md:grid-cols-3">
|
<section className="grid gap-5 md:grid-cols-3">
|
||||||
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
<Card className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Auth
|
Auth
|
||||||
</p>
|
</p>
|
||||||
<CardTitle className="text-lg text-slate-900">Cookie-based sessie actief</CardTitle>
|
<CardTitle className="text-lg text-foreground">Cookie-based sessie actief</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-6">
|
<CardContent className="pb-6">
|
||||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
|
@ -139,27 +112,45 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
<Card className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Profiel
|
Profiel
|
||||||
</p>
|
</p>
|
||||||
<CardTitle className="text-lg text-slate-900">{profileTitle}</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-6">
|
<CardContent className="space-y-4 pb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ProfileAvatar
|
||||||
|
avatarUrl={profile.avatarUrl}
|
||||||
|
displayName={profile.displayName}
|
||||||
|
email={profile.email}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-lg text-foreground">{profileTitle}</CardTitle>
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{profile.tagline ?? "Nog geen korte profielregel toegevoegd."}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
Taal `{profile.locale}` en timezone `{profile.timezone}` staan nu per
|
Taal `{profile.locale}` en timezone `{profile.timezone}` staan nu per
|
||||||
gebruiker opgeslagen.
|
gebruiker opgeslagen.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
{profile.bio ? (
|
||||||
|
<CardDescription className="whitespace-pre-line text-sm leading-7 text-muted-foreground">
|
||||||
|
{profile.bio}
|
||||||
|
</CardDescription>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
<Card className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Onboarding
|
Onboarding
|
||||||
</p>
|
</p>
|
||||||
<CardTitle className="text-lg text-slate-900">{onboardingState}</CardTitle>
|
<CardTitle className="text-lg text-foreground">{onboardingState}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-6">
|
<CardContent className="pb-6">
|
||||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
|
@ -169,12 +160,12 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
<Card className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Instellingen
|
Instellingen
|
||||||
</p>
|
</p>
|
||||||
<CardTitle className="text-lg text-slate-900">
|
<CardTitle className="text-lg text-foreground">
|
||||||
Punten {formatToggleState(settings.showEnergyPoints, "zichtbaar", "verborgen")}
|
Punten {formatToggleState(settings.showEnergyPoints, "zichtbaar", "verborgen")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -185,31 +176,68 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<CheckInCard todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
|
||||||
|
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Dagplanning
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-lg text-foreground">
|
||||||
|
{planningStatus?.activities.length
|
||||||
|
? `${planningStatus.activities.length} activiteiten voor vandaag`
|
||||||
|
: "Nog niets toegevoegd voor vandaag"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Plan kleine, concrete activiteiten voor vandaag en leg ook onverwachte activiteiten vast als je dag anders loopt dan gedacht.
|
||||||
|
</CardDescription>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Link href="/planning" className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary">
|
||||||
|
Open dagplanning
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<EnergyMeterCard meter={planningMeter} tone="subtle" />
|
||||||
|
|
||||||
|
{isTestWizardEnabled() ? (
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Wizard core
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-lg text-foreground">Interne testwizard actief</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Gebruik deze alleen in development of preview om nieuwe multi-step flows te controleren.
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!profile.onboardingCompleted ? (
|
{!profile.onboardingCompleted ? (
|
||||||
<Card className="rounded-[1.75rem] border border-amber-900/15 bg-amber-50 py-0 text-amber-950 shadow-[0_12px_40px_rgba(146,64,14,0.08)]">
|
<Card className="border-warning/32 bg-warning/16 py-0 text-foreground shadow-[var(--shadow-1)]">
|
||||||
<CardContent className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
<CardContent className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Je onboarding is nog niet afgerond.</p>
|
<p className="font-semibold">Je onboarding is nog niet afgerond.</p>
|
||||||
<p className="mt-1 max-w-2xl text-sm leading-7 text-amber-900">
|
<p className="mt-1 max-w-2xl text-sm leading-7 text-foreground/82">
|
||||||
Je kunt de korte flow later alsnog afronden om je basisinstellingen
|
Je kunt de korte flow later alsnog afronden om je basisinstellingen
|
||||||
en eerste voorkeuren vast te leggen.
|
en eerste voorkeuren vast te leggen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link href="/onboarding" className="inline-flex items-center rounded-full bg-warning px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:brightness-[0.98]">
|
||||||
href="/onboarding"
|
Rond onboarding af
|
||||||
className={cn(
|
</Link>
|
||||||
buttonVariants({ size: "lg" }),
|
|
||||||
"h-11 shrink-0 rounded-full bg-amber-950 px-5 text-amber-50 hover:bg-amber-900",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Rond onboarding af
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="rounded-[1.75rem] border border-primary/10 bg-primary py-0 text-primary-foreground shadow-[0_12px_40px_rgba(22,58,43,0.18)]">
|
<Card tone="primary" elevation="raised" className="py-0">
|
||||||
<CardContent className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
<CardContent className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Je instellingen kun je nu ook los beheren.</p>
|
<p className="font-semibold">Je instellingen kun je nu ook los beheren.</p>
|
||||||
|
|
@ -218,19 +246,13 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
timezone en zichtbaarheid van punten later zelfstandig kunt aanpassen.
|
timezone en zichtbaarheid van punten later zelfstandig kunt aanpassen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link href="/settings" className="inline-flex items-center rounded-full bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground shadow-[var(--shadow-1)] transition-colors hover:brightness-[0.98]">
|
||||||
href="/settings"
|
Open instellingen
|
||||||
className={cn(
|
</Link>
|
||||||
buttonVariants({ variant: "secondary", size: "lg" }),
|
</CardContent>
|
||||||
"h-11 shrink-0 rounded-full px-5",
|
</Card>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
Open instellingen
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
app/favicon.ico
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
272
app/globals.css
|
|
@ -5,42 +5,52 @@
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-display: "Iowan Old Style", "Palatino Linotype", "URW Palladio L",
|
--font-body: var(--font-inter-tight), ui-sans-serif, system-ui, sans-serif;
|
||||||
Palatino, Georgia, serif;
|
--font-display: var(--font-body);
|
||||||
--font-body: "Inter", "Aptos", "Segoe UI", "Helvetica Neue", Arial,
|
--font-mono: var(--font-plex-mono), ui-monospace, monospace;
|
||||||
sans-serif;
|
|
||||||
--background: #f5f4ee;
|
--background: oklch(97% 0.008 80);
|
||||||
--foreground: #0f172a;
|
--foreground: oklch(22% 0.03 262);
|
||||||
--card: rgb(255 255 255 / 0.84);
|
--card: oklch(99% 0.004 80);
|
||||||
--card-foreground: #0f172a;
|
--card-foreground: oklch(22% 0.03 262);
|
||||||
--popover: #ffffff;
|
--popover: oklch(100% 0 0);
|
||||||
--popover-foreground: #0f172a;
|
--popover-foreground: oklch(22% 0.03 262);
|
||||||
--primary: #163a2b;
|
|
||||||
--primary-foreground: #effaf3;
|
--primary: oklch(44% 0.11 262);
|
||||||
--secondary: #e5ecde;
|
--primary-foreground: oklch(98% 0.01 262);
|
||||||
--secondary-foreground: #163a2b;
|
--secondary: oklch(92% 0.03 262);
|
||||||
--muted: #eef2e6;
|
--secondary-foreground: oklch(44% 0.11 262);
|
||||||
--muted-foreground: #51606f;
|
--muted: oklch(95% 0.012 82);
|
||||||
--accent: #dbe7d1;
|
--muted-foreground: oklch(58% 0.015 262);
|
||||||
--accent-foreground: #163a2b;
|
--accent: oklch(92% 0.03 262);
|
||||||
--destructive: #b91c1c;
|
--accent-foreground: oklch(44% 0.11 262);
|
||||||
--border: rgb(15 23 42 / 0.1);
|
|
||||||
--input: rgb(15 23 42 / 0.12);
|
--destructive: oklch(58% 0.16 25);
|
||||||
--ring: #5d8a67;
|
--success: oklch(62% 0.09 155);
|
||||||
--chart-1: #163a2b;
|
--warning: oklch(72% 0.1 70);
|
||||||
--chart-2: #5d8a67;
|
|
||||||
--chart-3: #90a955;
|
--border: oklch(22% 0.03 262 / 0.1);
|
||||||
--chart-4: #b7c5a4;
|
--input: oklch(22% 0.03 262 / 0.12);
|
||||||
--chart-5: #d7dfce;
|
--ring: oklch(44% 0.11 262);
|
||||||
--radius: 1rem;
|
|
||||||
--sidebar: #fbfaf5;
|
--chart-1: oklch(44% 0.11 262);
|
||||||
--sidebar-foreground: #0f172a;
|
--chart-2: oklch(60% 0.09 262);
|
||||||
--sidebar-primary: #163a2b;
|
--chart-3: oklch(70% 0.1 50);
|
||||||
--sidebar-primary-foreground: #effaf3;
|
--chart-4: oklch(62% 0.09 155);
|
||||||
--sidebar-accent: #dbe7d1;
|
--chart-5: oklch(80% 0.03 262);
|
||||||
--sidebar-accent-foreground: #163a2b;
|
|
||||||
--sidebar-border: rgb(15 23 42 / 0.08);
|
--radius: 14px;
|
||||||
--sidebar-ring: #5d8a67;
|
--shadow-1: 0 1px 3px oklch(22% 0.03 262 / 0.1), 0 10px 30px oklch(22% 0.03 262 / 0.06);
|
||||||
|
--shadow-2: 0 4px 16px oklch(22% 0.03 262 / 0.14), 0 18px 45px oklch(22% 0.03 262 / 0.12);
|
||||||
|
--shadow-3: 0 24px 60px oklch(22% 0.03 262 / 0.18);
|
||||||
|
--sidebar: oklch(96.5% 0.01 80);
|
||||||
|
--sidebar-foreground: oklch(22% 0.03 262);
|
||||||
|
--sidebar-primary: oklch(44% 0.11 262);
|
||||||
|
--sidebar-primary-foreground: oklch(98% 0.01 262);
|
||||||
|
--sidebar-accent: oklch(92% 0.03 262);
|
||||||
|
--sidebar-accent-foreground: oklch(44% 0.11 262);
|
||||||
|
--sidebar-border: oklch(22% 0.03 262 / 0.08);
|
||||||
|
--sidebar-ring: oklch(44% 0.11 262);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
@ -79,6 +89,8 @@ a {
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-success: var(--success);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
|
|
@ -97,47 +109,60 @@ a {
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--font-mono: var(--font-plex-mono), ui-monospace, monospace;
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 11px;
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
--radius-xl: 21px;
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: 25px;
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: 32px;
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: 40px;
|
||||||
|
--radius-full: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: #111927;
|
--background: oklch(17% 0.02 262);
|
||||||
--foreground: #eef6f0;
|
--foreground: oklch(96% 0.008 80);
|
||||||
--card: rgb(17 25 39 / 0.84);
|
--card: oklch(22% 0.025 262);
|
||||||
--card-foreground: #eef6f0;
|
--card-foreground: oklch(96% 0.008 80);
|
||||||
--popover: #152131;
|
--popover: oklch(22% 0.025 262);
|
||||||
--popover-foreground: #eef6f0;
|
--popover-foreground: oklch(96% 0.008 80);
|
||||||
--primary: #d9f2de;
|
|
||||||
--primary-foreground: #133225;
|
--primary: oklch(52% 0.13 262);
|
||||||
--secondary: #243244;
|
--primary-foreground: oklch(98% 0.01 262);
|
||||||
--secondary-foreground: #eef6f0;
|
--secondary: oklch(28% 0.03 262);
|
||||||
--muted: #243244;
|
--secondary-foreground: oklch(96% 0.008 80);
|
||||||
--muted-foreground: #b1bec8;
|
--muted: oklch(26% 0.025 262);
|
||||||
--accent: #31485b;
|
--muted-foreground: oklch(70% 0.015 262);
|
||||||
--accent-foreground: #eef6f0;
|
--accent: oklch(30% 0.04 262);
|
||||||
--destructive: #ef4444;
|
--accent-foreground: oklch(96% 0.008 80);
|
||||||
--border: rgb(255 255 255 / 0.12);
|
|
||||||
--input: rgb(255 255 255 / 0.14);
|
--destructive: oklch(70% 0.16 25);
|
||||||
--ring: #88b593;
|
--success: oklch(74% 0.09 155);
|
||||||
--chart-1: #d9f2de;
|
--warning: oklch(80% 0.1 70);
|
||||||
--chart-2: #88b593;
|
|
||||||
--chart-3: #90a955;
|
--border: oklch(100% 0 0 / 0.1);
|
||||||
--chart-4: #51606f;
|
--input: oklch(100% 0 0 / 0.1);
|
||||||
--chart-5: #243244;
|
--ring: oklch(78% 0.08 262);
|
||||||
--sidebar: #152131;
|
|
||||||
--sidebar-foreground: #eef6f0;
|
--chart-1: oklch(78% 0.08 262);
|
||||||
--sidebar-primary: #d9f2de;
|
--chart-2: oklch(60% 0.09 262);
|
||||||
--sidebar-primary-foreground: #133225;
|
--chart-3: oklch(80% 0.1 70);
|
||||||
--sidebar-accent: #243244;
|
--chart-4: oklch(74% 0.09 155);
|
||||||
--sidebar-accent-foreground: #eef6f0;
|
--chart-5: oklch(36% 0.03 262);
|
||||||
--sidebar-border: rgb(255 255 255 / 0.12);
|
|
||||||
--sidebar-ring: #88b593;
|
--shadow-1: 0 1px 3px oklch(0% 0 0 / 0.24), 0 10px 30px oklch(0% 0 0 / 0.2);
|
||||||
|
--shadow-2: 0 4px 16px oklch(0% 0 0 / 0.28), 0 18px 45px oklch(0% 0 0 / 0.24);
|
||||||
|
--shadow-3: 0 24px 60px oklch(0% 0 0 / 0.32);
|
||||||
|
|
||||||
|
--sidebar: oklch(20% 0.022 262);
|
||||||
|
--sidebar-foreground: oklch(96% 0.008 80);
|
||||||
|
--sidebar-primary: oklch(52% 0.13 262);
|
||||||
|
--sidebar-primary-foreground: oklch(98% 0.01 262);
|
||||||
|
--sidebar-accent: oklch(28% 0.03 262);
|
||||||
|
--sidebar-accent-foreground: oklch(96% 0.008 80);
|
||||||
|
--sidebar-border: oklch(100% 0 0 / 0.1);
|
||||||
|
--sidebar-ring: oklch(78% 0.08 262);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|
@ -152,12 +177,113 @@ a {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
font-family: var(--font-body), sans-serif;
|
font-family: var(--font-body), sans-serif;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
pre {
|
||||||
|
font-family: var(--font-mono), monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.app-page {
|
||||||
|
@apply min-h-screen px-6 py-10 text-foreground sm:px-8;
|
||||||
|
background-color: var(--background);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(
|
||||||
|
circle at top,
|
||||||
|
color-mix(in oklab, var(--primary) 16%, transparent) 0%,
|
||||||
|
transparent 34%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in oklab, var(--background) 96%, var(--secondary) 4%) 0%,
|
||||||
|
color-mix(in oklab, var(--background) 86%, var(--secondary) 14%) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .app-page {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(
|
||||||
|
circle at top,
|
||||||
|
color-mix(in oklab, var(--primary) 22%, transparent) 0%,
|
||||||
|
transparent 38%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in oklab, var(--background) 96%, var(--secondary) 4%) 0%,
|
||||||
|
color-mix(in oklab, var(--background) 88%, var(--secondary) 12%) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-header {
|
||||||
|
@apply flex flex-col gap-5 rounded-[var(--radius-4xl)] p-6 sm:flex-row sm:items-start sm:justify-between sm:p-8;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--border) 88%, transparent);
|
||||||
|
background: color-mix(in oklab, var(--card) 84%, transparent);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-breadcrumb {
|
||||||
|
@apply flex flex-wrap items-center gap-3 text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-link {
|
||||||
|
@apply transition hover:text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-copy {
|
||||||
|
@apply mt-4 max-w-2xl text-base leading-8 text-muted-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-panel-primary {
|
||||||
|
@apply text-primary-foreground;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--primary) 16%, transparent);
|
||||||
|
background: var(--primary);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cn-toast {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--border) 92%, transparent);
|
||||||
|
box-shadow: var(--shadow-3);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
app/icon.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="20" y="20" width="472" height="472" rx="116" fill="#F4EFE5"/>
|
||||||
|
<circle
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="144"
|
||||||
|
fill="none"
|
||||||
|
stroke="#20493B"
|
||||||
|
stroke-width="58"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="660 210"
|
||||||
|
transform="rotate(-34 256 256)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M164 294C189 274 221 264 256 264C291 264 323 274 348 294"
|
||||||
|
stroke="#D8C3A5"
|
||||||
|
stroke-width="30"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M192 320C210 304 233 296 256 296C279 296 302 304 320 320"
|
||||||
|
stroke="#A7C957"
|
||||||
|
stroke-width="18"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="362" cy="171" r="24" fill="#A7C957"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 734 B |
|
|
@ -1,6 +1,22 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { IBM_Plex_Mono, Inter_Tight } from "next/font/google";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
const fontBody = Inter_Tight({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-inter-tight",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fontMono = IBM_Plex_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500"],
|
||||||
|
variable: "--font-plex-mono",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Inspannings Monitor",
|
title: "Inspannings Monitor",
|
||||||
description:
|
description:
|
||||||
|
|
@ -13,8 +29,21 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="nl">
|
<html
|
||||||
<body className="min-h-screen">{children}</body>
|
lang="nl"
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={`${fontBody.variable} ${fontMono.variable} dark`}
|
||||||
|
>
|
||||||
|
<body className="min-h-screen antialiased">
|
||||||
|
<ThemeProvider
|
||||||
|
defaultTheme="dark"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Toaster position="top-right" richColors closeButton />
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,23 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthNotice } from "@/components/auth/auth-notice";
|
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||||
import { AuthPanel } from "@/components/auth/auth-panel";
|
import { AuthPanel } from "@/components/auth/auth-panel";
|
||||||
import { signInAction } from "@/app/auth-actions";
|
import { signInAction } from "@/app/auth-actions";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getAuthNotice } from "@/lib/auth/messages";
|
|
||||||
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
|
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
import { getAuthState } from "@/lib/auth/session";
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { getAuthStatusToast } from "@/lib/feedback/status-messages";
|
||||||
|
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type LoginPageProps = {
|
type LoginPageProps = {
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<PageSearchParams>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getParamValue(
|
|
||||||
params: Record<string, string | string[] | undefined>,
|
|
||||||
key: string,
|
|
||||||
) {
|
|
||||||
const value = params[key];
|
|
||||||
return typeof value === "string" ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
|
|
@ -34,7 +27,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
redirect(next);
|
redirect(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notice = getAuthNotice(
|
const statusToast = getAuthStatusToast(
|
||||||
getParamValue(resolvedSearchParams, "error"),
|
getParamValue(resolvedSearchParams, "error"),
|
||||||
getParamValue(resolvedSearchParams, "status"),
|
getParamValue(resolvedSearchParams, "status"),
|
||||||
);
|
);
|
||||||
|
|
@ -48,16 +41,16 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
footer={
|
footer={
|
||||||
<p>
|
<p>
|
||||||
Nog geen account?{" "}
|
Nog geen account?{" "}
|
||||||
<Link href={signUpHref} className="font-semibold text-emerald-900">
|
<Link href={signUpHref} className="font-semibold text-primary underline-offset-4 hover:underline">
|
||||||
Maak er een aan
|
Maak er een aan
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AuthNotice notice={notice} />
|
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||||
|
|
||||||
{!authState.isConfigured ? (
|
{!authState.isConfigured ? (
|
||||||
<Alert className="rounded-[1.5rem] border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700">
|
<Alert variant="info">
|
||||||
<AlertDescription className="leading-7 text-current">
|
<AlertDescription className="leading-7 text-current">
|
||||||
Voeg eerst je Supabase-gegevens toe in `.env.local` op basis van `.env.example`.
|
Voeg eerst je Supabase-gegevens toe in `.env.local` op basis van `.env.example`.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|
@ -67,7 +60,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
<input type="hidden" name="next" value={next} />
|
<input type="hidden" name="next" value={next} />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email" className="text-slate-800">
|
<Label htmlFor="email" className="text-foreground">
|
||||||
E-mailadres
|
E-mailadres
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -81,7 +74,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-slate-800">
|
<Label htmlFor="password" className="text-foreground">
|
||||||
Wachtwoord
|
Wachtwoord
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -2,38 +2,80 @@
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { buildPathWithQuery } from "@/lib/auth/navigation";
|
import { buildPathWithQuery } from "@/lib/auth/navigation";
|
||||||
|
import {
|
||||||
|
FormDataValidationError,
|
||||||
|
getBooleanValue,
|
||||||
|
getEnumValue,
|
||||||
|
getOptionalString,
|
||||||
|
getOptionalTimeValue,
|
||||||
|
} from "@/lib/forms/parse";
|
||||||
|
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
||||||
import {
|
import {
|
||||||
completeOnboardingForCurrentUser,
|
completeOnboardingForCurrentUser,
|
||||||
markOnboardingSeenForCurrentUser,
|
markOnboardingSeenForCurrentUser,
|
||||||
} from "@/lib/profile/service";
|
} from "@/lib/profile/service";
|
||||||
import type { OnboardingSubmission } from "@/lib/profile/types";
|
import type { OnboardingSubmission } from "@/lib/profile/types";
|
||||||
|
|
||||||
function getString(formData: FormData, key: string) {
|
const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value);
|
||||||
const value = formData.get(key);
|
|
||||||
return typeof value === "string" ? value.trim() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBoolean(formData: FormData, key: string) {
|
|
||||||
return formData.get(key) === "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOnboardingSubmission(formData: FormData): OnboardingSubmission {
|
function buildOnboardingSubmission(formData: FormData): OnboardingSubmission {
|
||||||
|
const morningReminderEnabled = getBooleanValue(
|
||||||
|
formData,
|
||||||
|
"morningReminderEnabled",
|
||||||
|
"invalid-onboarding-input",
|
||||||
|
);
|
||||||
|
const reminderTime = getOptionalTimeValue(
|
||||||
|
formData,
|
||||||
|
"morningReminderTime",
|
||||||
|
"invalid-onboarding-input",
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayName: getString(formData, "displayName") || null,
|
displayName: getOptionalString(formData, "displayName") || null,
|
||||||
timezone: getString(formData, "timezone"),
|
timezone: getEnumValue(
|
||||||
morningReminderEnabled: getBoolean(formData, "morningReminderEnabled"),
|
formData,
|
||||||
morningReminderTime: getString(formData, "morningReminderTime") || null,
|
"timezone",
|
||||||
reflectionReminderEnabled: getBoolean(formData, "reflectionReminderEnabled"),
|
ONBOARDING_TIMEZONE_VALUES,
|
||||||
showEnergyPoints: getBoolean(formData, "showEnergyPoints"),
|
"invalid-onboarding-input",
|
||||||
|
),
|
||||||
|
morningReminderEnabled,
|
||||||
|
morningReminderTime: morningReminderEnabled ? reminderTime : null,
|
||||||
|
reflectionReminderEnabled: getBooleanValue(
|
||||||
|
formData,
|
||||||
|
"reflectionReminderEnabled",
|
||||||
|
"invalid-onboarding-input",
|
||||||
|
),
|
||||||
|
showEnergyPoints: getBooleanValue(
|
||||||
|
formData,
|
||||||
|
"showEnergyPoints",
|
||||||
|
"invalid-onboarding-input",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeOnboardingAction(formData: FormData) {
|
export async function completeOnboardingAction(
|
||||||
await completeOnboardingForCurrentUser(buildOnboardingSubmission(formData));
|
_previousState: null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<null> {
|
||||||
|
try {
|
||||||
|
await completeOnboardingForCurrentUser(buildOnboardingSubmission(formData));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormDataValidationError) {
|
||||||
|
redirect(buildPathWithQuery("/onboarding", { error: error.code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-completed" }));
|
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-completed" }));
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function skipOnboardingAction() {
|
export async function skipOnboardingAction(
|
||||||
|
_previousState: null,
|
||||||
|
_formData: FormData,
|
||||||
|
): Promise<null> {
|
||||||
await markOnboardingSeenForCurrentUser();
|
await markOnboardingSeenForCurrentUser();
|
||||||
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-skipped" }));
|
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-skipped" }));
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||||
|
import { AppShell } from "@/components/navigation/app-shell";
|
||||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||||
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
import { getAuthState } from "@/lib/auth/session";
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { getOnboardingStatusToast } from "@/lib/feedback/status-messages";
|
||||||
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||||
|
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function OnboardingPage() {
|
type OnboardingPageProps = {
|
||||||
|
searchParams: Promise<PageSearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OnboardingPage({ searchParams }: OnboardingPageProps) {
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
|
||||||
if (!authState.isConfigured) {
|
if (!authState.isConfigured) {
|
||||||
redirect("/login?error=auth-not-configured");
|
redirect("/login?error=auth-not-configured");
|
||||||
|
|
@ -27,11 +36,17 @@ export default async function OnboardingPage() {
|
||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusToast = getOnboardingStatusToast(
|
||||||
|
getParamValue(resolvedSearchParams, "error"),
|
||||||
|
getParamValue(resolvedSearchParams, "status"),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
|
<AppShell contentClassName="space-y-8">
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
<div className="space-y-8">
|
||||||
|
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||||
<OnboardingFlow profileBundle={profileBundle} />
|
<OnboardingFlow profileBundle={profileBundle} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
214
app/page.tsx
|
|
@ -1,7 +1,7 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { signOutAction } from "@/app/auth-actions";
|
import { AppShell } from "@/components/navigation/app-shell";
|
||||||
import { AuthNotice } from "@/components/auth/auth-notice";
|
import { PageIntro } from "@/components/navigation/page-intro";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -9,13 +9,12 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { getAuthNotice } from "@/lib/auth/messages";
|
import { getAuthStatusToast } from "@/lib/feedback/status-messages";
|
||||||
import { getAuthState } from "@/lib/auth/session";
|
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const loopSteps = [
|
const productLoop = [
|
||||||
{
|
{
|
||||||
title: "Check-in",
|
title: "Check-in",
|
||||||
copy: "Start de dag met een korte energiescore en slaapkwaliteit, zonder overbodige frictie.",
|
copy: "Start de dag met een korte energiescore en slaapkwaliteit, zonder overbodige frictie.",
|
||||||
|
|
@ -30,122 +29,64 @@ const loopSteps = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const releaseFocus = [
|
const makerNotes = [
|
||||||
|
"Jan Peter Visser ontwikkelt deze app als rustige, praktische dagtool.",
|
||||||
|
"De app is bewust gericht op helderheid, lage cognitieve belasting en een wellness-first toon.",
|
||||||
|
"Elke stap wordt klein gehouden zodat de flow bruikbaar blijft zonder medische framing.",
|
||||||
|
];
|
||||||
|
|
||||||
|
const appSpecs = [
|
||||||
"Alleen individuele gebruikers in release 1",
|
"Alleen individuele gebruikers in release 1",
|
||||||
|
"Volwassen doelgroep en Nederlands als voertaal",
|
||||||
"Wellness/self-management positionering",
|
"Wellness/self-management positionering",
|
||||||
"Geen sharing, AI of medische workflows in de MVP",
|
"Geen sharing, AI of medische workflows in de MVP",
|
||||||
"Vercel + Supabase als technische basis",
|
"Vercel + Supabase als technische basis",
|
||||||
];
|
];
|
||||||
|
|
||||||
type HomePageProps = {
|
type HomePageProps = {
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<PageSearchParams>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getParamValue(
|
|
||||||
params: Record<string, string | string[] | undefined>,
|
|
||||||
key: string,
|
|
||||||
) {
|
|
||||||
const value = params[key];
|
|
||||||
return typeof value === "string" ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Home({ searchParams }: HomePageProps) {
|
export default async function Home({ searchParams }: HomePageProps) {
|
||||||
const authState = await getAuthState();
|
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const notice = getAuthNotice(
|
const statusToast = getAuthStatusToast(
|
||||||
getParamValue(resolvedSearchParams, "error"),
|
getParamValue(resolvedSearchParams, "error"),
|
||||||
getParamValue(resolvedSearchParams, "status"),
|
getParamValue(resolvedSearchParams, "status"),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] text-slate-900">
|
<AppShell contentClassName="space-y-8">
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-10 sm:px-8 lg:px-10">
|
<div className="space-y-8">
|
||||||
<header className="mb-10 flex items-center justify-between border-b border-black/10 pb-5">
|
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-600">
|
|
||||||
Inspannings Monitor
|
|
||||||
</p>
|
|
||||||
<h1 className="font-[family-name:var(--font-display)] text-3xl leading-tight sm:text-5xl">
|
|
||||||
Rustige basis voor een wellness-first MVP
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
|
||||||
{authState.isConfigured ? (
|
|
||||||
authState.isAuthenticated ? (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "outline", size: "lg" }),
|
|
||||||
"h-11 shrink-0 whitespace-nowrap rounded-full px-5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Naar dashboard
|
|
||||||
</Link>
|
|
||||||
<form action={signOutAction}>
|
|
||||||
<Button type="submit" variant="outline" size="lg" className="h-11 shrink-0 whitespace-nowrap rounded-full px-5">
|
|
||||||
Uitloggen
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "outline", size: "lg" }),
|
|
||||||
"h-11 shrink-0 whitespace-nowrap rounded-full px-5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Inloggen
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/sign-up"
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "outline", size: "lg" }),
|
|
||||||
"h-11 shrink-0 whitespace-nowrap rounded-full px-5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Account aanmaken
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="rounded-full border border-amber-900/15 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-900 shadow-sm">
|
|
||||||
Supabase nog niet geconfigureerd
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<AuthNotice notice={notice} />
|
<PageIntro
|
||||||
|
eyebrow="About"
|
||||||
|
title="Over de maker en de app"
|
||||||
|
description="Inspannings Monitor is een rustige wellness-first webapp voor volwassenen die hun energie willen plannen, uitvoeren en evalueren zonder medische claims of overmatige frictie."
|
||||||
|
aside={
|
||||||
|
<Link
|
||||||
|
href="/planning"
|
||||||
|
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Bekijk planning
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="grid gap-6 lg:grid-cols-[1.35fr_0.95fr]">
|
<section className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
<Card className="rounded-[2rem] border border-border/60 bg-card/90 py-0 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur">
|
<Card elevation="raised" className="py-0">
|
||||||
<CardContent className="p-6 sm:p-8">
|
<CardContent className="p-6 sm:p-8">
|
||||||
<p className="mb-4 max-w-2xl text-lg leading-8 text-slate-700">
|
<p className="mb-5 max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||||
De projectbasis staat nu, inclusief de eerste auth-laag via Supabase.
|
Deze app wordt ontwikkeld door Jan Peter Visser als compacte dagtool
|
||||||
Release 1 blijft bewust smal: publieke landing, aparte login/signup
|
voor energieplanning en zelfregie. Het doel is niet om te diagnosticeren
|
||||||
routes en een eerste protected dashboard als basis voor de volgende stories.
|
of te behandelen, maar om een rustige plan-doe-evalueer-structuur te bieden
|
||||||
|
die licht genoeg blijft voor dagelijks gebruik.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4">
|
||||||
{loopSteps.map((step, index) => (
|
{makerNotes.map((note) => (
|
||||||
<Card
|
<Card key={note} tone="subtle" className="py-0 shadow-none">
|
||||||
key={step.title}
|
<CardContent className="px-5 py-4 text-sm leading-7 text-muted-foreground">
|
||||||
className="rounded-[1.5rem] border border-border/50 bg-background/80 py-0 shadow-none"
|
{note}
|
||||||
>
|
|
||||||
<CardHeader className="pb-0">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
|
||||||
Stap {index + 1}
|
|
||||||
</p>
|
|
||||||
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
|
||||||
{step.title}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-5">
|
|
||||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
|
||||||
{step.copy}
|
|
||||||
</CardDescription>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
@ -153,63 +94,86 @@ export default async function Home({ searchParams }: HomePageProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="rounded-[2rem] border border-primary/10 bg-primary py-0 text-primary-foreground shadow-[0_18px_60px_rgba(22,58,43,0.18)]">
|
<Card tone="primary" elevation="raised" className="py-0">
|
||||||
<CardHeader className="px-6 pt-7 sm:px-8">
|
<CardHeader className="px-6 pt-7 sm:px-8">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
||||||
Release 1 focus
|
Specificaties van de app
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 px-6 pb-7 sm:px-8">
|
<CardContent className="space-y-3 px-6 pb-7 sm:px-8">
|
||||||
{releaseFocus.map((item) => (
|
{appSpecs.map((item) => (
|
||||||
<Card
|
<Card
|
||||||
key={item}
|
key={item}
|
||||||
className="rounded-[1.5rem] border border-white/10 bg-white/8 py-0 text-primary-foreground shadow-none"
|
className="rounded-[var(--radius-2xl)] border-white/10 bg-white/8 py-0 text-primary-foreground shadow-none"
|
||||||
>
|
>
|
||||||
<CardContent className="px-4 py-3 text-sm leading-7">{item}</CardContent>
|
<CardContent className="px-4 py-3 text-sm leading-7">{item}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
{authState.isConfigured ? (
|
<p className="pt-2 text-sm leading-7 text-primary-foreground/80">
|
||||||
<p className="pt-2 text-sm leading-7 text-primary-foreground/80">
|
De huidige codebasis bevat al auth, onboarding, ochtendcheck-in,
|
||||||
Auth is ingericht met e-mail, wachtwoord en verplichte e-mailverificatie.
|
planning, energiemeter en Dusk-theming.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
|
||||||
<p className="pt-2 text-sm leading-7 text-primary-foreground/80">
|
|
||||||
Voeg `.env.local` toe om login, signup en protected routes lokaal te activeren.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card className="mt-8 rounded-[2rem] border border-border/60 bg-card/80 py-0 shadow-[0_10px_45px_rgba(71,85,105,0.08)] backdrop-blur">
|
<Card elevation="raised" className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Dagflow
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-2xl">De hoofdstructuur van release 1</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-5 p-6 md:grid-cols-3">
|
||||||
|
{productLoop.map((step, index) => (
|
||||||
|
<Card key={step.title} tone="subtle" className="py-0 shadow-none">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
|
Stap {index + 1}
|
||||||
|
</p>
|
||||||
|
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
||||||
|
{step.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-5">
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{step.copy}
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card tone="subtle" className="py-0 backdrop-blur">
|
||||||
<CardContent className="grid gap-5 p-6 sm:grid-cols-2 lg:grid-cols-4">
|
<CardContent className="grid gap-5 p-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Volgende story
|
Positionering
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 font-semibold text-slate-900">ST-201 Ochtendcheck-in</p>
|
<p className="mt-2 font-semibold text-foreground">Wellness / self-management</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Doelgroep
|
Doelgroep
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 font-semibold text-slate-900">Volwassen individuele gebruikers</p>
|
<p className="mt-2 font-semibold text-foreground">Volwassen individuele gebruikers</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Positionering
|
Auth en data
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 font-semibold text-slate-900">Wellness / self-management</p>
|
<p className="mt-2 font-semibold text-foreground">Supabase Auth + PostgreSQL</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Status
|
Hosting
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 font-semibold text-slate-900">Auth, onboarding en settings actief</p>
|
<p className="mt-2 font-semibold text-foreground">Next.js op Vercel</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
221
app/planning/actions.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { buildPathWithQuery } from "@/lib/auth/navigation";
|
||||||
|
import {
|
||||||
|
ACTIVITY_IMPACT_LEVEL_VALUES,
|
||||||
|
ACTIVITY_PRIORITY_LEVEL_VALUES,
|
||||||
|
ACTIVITY_STATUS_VALUES,
|
||||||
|
} from "@/lib/planning/options";
|
||||||
|
import {
|
||||||
|
createAdHocActivityForTodayForCurrentUser,
|
||||||
|
createActivityForTodayForCurrentUser,
|
||||||
|
updateActivityEvaluationForTodayForCurrentUser,
|
||||||
|
updateActivityStatusForTodayForCurrentUser,
|
||||||
|
} from "@/lib/planning/service";
|
||||||
|
import type {
|
||||||
|
CreateAdHocActivitySubmission,
|
||||||
|
CreateActivitySubmission,
|
||||||
|
UpdateActivityEvaluationSubmission,
|
||||||
|
UpdateActivityStatusSubmission,
|
||||||
|
} from "@/lib/planning/types";
|
||||||
|
import {
|
||||||
|
assertMaxLength,
|
||||||
|
FormDataValidationError,
|
||||||
|
getEnumValue,
|
||||||
|
getIntegerValue,
|
||||||
|
getOptionalString,
|
||||||
|
getOptionalUuidValue,
|
||||||
|
getRequiredString,
|
||||||
|
getUuidValue,
|
||||||
|
} from "@/lib/forms/parse";
|
||||||
|
|
||||||
|
function buildCreateActivitySubmission(formData: FormData): CreateActivitySubmission {
|
||||||
|
const name = assertMaxLength(
|
||||||
|
getRequiredString(formData, "name", "invalid-activity-input"),
|
||||||
|
120,
|
||||||
|
"invalid-activity-input",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
categoryId: getUuidValue(formData, "categoryId", "invalid-activity-input"),
|
||||||
|
durationMinutes: getIntegerValue(
|
||||||
|
formData,
|
||||||
|
"durationMinutes",
|
||||||
|
{ min: 1, max: 720 },
|
||||||
|
"invalid-activity-input",
|
||||||
|
),
|
||||||
|
impactLevel: getEnumValue(
|
||||||
|
formData,
|
||||||
|
"impactLevel",
|
||||||
|
ACTIVITY_IMPACT_LEVEL_VALUES,
|
||||||
|
"invalid-activity-input",
|
||||||
|
),
|
||||||
|
priorityLevel: getEnumValue(
|
||||||
|
formData,
|
||||||
|
"priorityLevel",
|
||||||
|
ACTIVITY_PRIORITY_LEVEL_VALUES,
|
||||||
|
"invalid-activity-input",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreateAdHocActivitySubmission(
|
||||||
|
formData: FormData,
|
||||||
|
): CreateAdHocActivitySubmission {
|
||||||
|
const name = assertMaxLength(
|
||||||
|
getRequiredString(formData, "name", "invalid-ad-hoc-activity-input"),
|
||||||
|
120,
|
||||||
|
"invalid-ad-hoc-activity-input",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
categoryId: getUuidValue(formData, "categoryId", "invalid-ad-hoc-activity-input"),
|
||||||
|
durationMinutes: getIntegerValue(
|
||||||
|
formData,
|
||||||
|
"durationMinutes",
|
||||||
|
{ min: 1, max: 720 },
|
||||||
|
"invalid-ad-hoc-activity-input",
|
||||||
|
),
|
||||||
|
impactLevel: getEnumValue(
|
||||||
|
formData,
|
||||||
|
"impactLevel",
|
||||||
|
ACTIVITY_IMPACT_LEVEL_VALUES,
|
||||||
|
"invalid-ad-hoc-activity-input",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpdateActivityStatusSubmission(
|
||||||
|
formData: FormData,
|
||||||
|
): UpdateActivityStatusSubmission {
|
||||||
|
return {
|
||||||
|
activityId: getUuidValue(formData, "activityId", "invalid-activity-status"),
|
||||||
|
status: getEnumValue(
|
||||||
|
formData,
|
||||||
|
"status",
|
||||||
|
ACTIVITY_STATUS_VALUES,
|
||||||
|
"invalid-activity-status",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpdateActivityEvaluationSubmission(
|
||||||
|
formData: FormData,
|
||||||
|
): UpdateActivityEvaluationSubmission {
|
||||||
|
const notes = getOptionalString(formData, "notes");
|
||||||
|
|
||||||
|
return {
|
||||||
|
activityId: getUuidValue(formData, "activityId", "invalid-activity-evaluation"),
|
||||||
|
skipReasonId: getOptionalUuidValue(
|
||||||
|
formData,
|
||||||
|
"skipReasonId",
|
||||||
|
"invalid-activity-evaluation",
|
||||||
|
),
|
||||||
|
notes: notes
|
||||||
|
? assertMaxLength(notes, 500, "invalid-activity-evaluation")
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createActivityAction(
|
||||||
|
_previousState: null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<null> {
|
||||||
|
try {
|
||||||
|
await createActivityForTodayForCurrentUser(buildCreateActivitySubmission(formData));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormDataValidationError) {
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: error.code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === "Ongeldige activiteitcategorie.") {
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: "invalid-activity-input" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/planning", { status: "activity-saved" }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdHocActivityAction(
|
||||||
|
_previousState: null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<null> {
|
||||||
|
try {
|
||||||
|
await createAdHocActivityForTodayForCurrentUser(
|
||||||
|
buildCreateAdHocActivitySubmission(formData),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormDataValidationError) {
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: error.code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === "Ongeldige activiteitcategorie.") {
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: "invalid-ad-hoc-activity-input" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: "ad-hoc-activity-failed" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/planning", { status: "ad-hoc-activity-saved" }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateActivityStatusAction(
|
||||||
|
_previousState: null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<null> {
|
||||||
|
try {
|
||||||
|
await updateActivityStatusForTodayForCurrentUser(
|
||||||
|
buildUpdateActivityStatusSubmission(formData),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormDataValidationError) {
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: error.code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === "Ongeldige of niet-beschikbare activiteit.") {
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: "invalid-activity-status" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: "activity-status-failed" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/planning", { status: "activity-status-saved" }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveActivityEvaluationAction(
|
||||||
|
_previousState: null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<null> {
|
||||||
|
try {
|
||||||
|
await updateActivityEvaluationForTodayForCurrentUser(
|
||||||
|
buildUpdateActivityEvaluationSubmission(formData),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormDataValidationError) {
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: error.code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message === "Ongeldige of niet-beschikbare activiteit." ||
|
||||||
|
error.message === "Skip-reden is verplicht voor een overgeslagen activiteit." ||
|
||||||
|
error.message === "Toelichting is verplicht voor een aangepaste activiteit." ||
|
||||||
|
error.message === "Ongeldige skip-reden.")
|
||||||
|
) {
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: "invalid-activity-evaluation" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/planning", { error: "activity-evaluation-failed" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildPathWithQuery("/planning", { status: "activity-evaluation-saved" }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
27
app/planning/error.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"use client";
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function PlanningError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-svh items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-md space-y-4">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Er is iets misgegaan</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
De planningpagina kon niet worden geladen. Probeer het opnieuw of
|
||||||
|
kom later terug.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button onClick={reset} variant="outline" className="w-full">
|
||||||
|
Opnieuw proberen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
app/planning/loading.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function PlanningLoading() {
|
||||||
|
return (
|
||||||
|
<main className="app-page">
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8 px-4 sm:px-6">
|
||||||
|
<div className="flex h-16 shrink-0 items-center justify-between border-b border-border/50">
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-7 w-7 rounded-full" />
|
||||||
|
<Skeleton className="h-7 w-7 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
<Skeleton className="h-7 w-56" />
|
||||||
|
<Skeleton className="h-4 w-80 max-w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardContent className="space-y-4 pb-6 pt-4">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-10 flex-1" />
|
||||||
|
<Skeleton className="h-10 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
<Skeleton className="mt-1 h-5 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<Skeleton className="mt-2 h-4 w-full" />
|
||||||
|
<Skeleton className="mt-1.5 h-4 w-3/4" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardContent className="space-y-3 pb-6 pt-4">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-5 w-full rounded-full" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 pb-6">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-5/6" />
|
||||||
|
<Skeleton className="h-4 w-4/6" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<Card key={i} className="py-0">
|
||||||
|
<CardContent className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
app/planning/page.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||||
|
import { AdHocActivityForm } from "@/components/planning/ad-hoc-activity-form";
|
||||||
|
import { AppShell } from "@/components/navigation/app-shell";
|
||||||
|
import { PageIntro } from "@/components/navigation/page-intro";
|
||||||
|
import { DayOverviewCard } from "@/components/planning/day-overview-card";
|
||||||
|
import { ActivityForm } from "@/components/planning/activity-form";
|
||||||
|
import { EnergyMeterCard } from "@/components/planning/energy-meter-card";
|
||||||
|
import { TodayActivitiesList } from "@/components/planning/today-activities-list";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { getTodayCheckInForCurrentUser } from "@/lib/check-in/service";
|
||||||
|
import { getPlanningStatusToast } from "@/lib/feedback/status-messages";
|
||||||
|
import { getPlanningPageDataForCurrentUser } from "@/lib/planning/service";
|
||||||
|
import { calculatePlanningMeterSnapshot } from "@/lib/planning/meter";
|
||||||
|
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||||
|
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type PlanningPageProps = {
|
||||||
|
searchParams: Promise<PageSearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PlanningPage({ searchParams }: PlanningPageProps) {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
|
||||||
|
if (!authState.isConfigured) {
|
||||||
|
redirect("/login?error=auth-not-configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/planning"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileBundle = await getProfileBundleForCurrentUser();
|
||||||
|
|
||||||
|
if (!profileBundle) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/planning"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profileBundle.profile.onboardingSeen) {
|
||||||
|
redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [planningPageData, checkInStatus] = await Promise.all([
|
||||||
|
getPlanningPageDataForCurrentUser(),
|
||||||
|
getTodayCheckInForCurrentUser(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!planningPageData) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/planning"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusToast = getPlanningStatusToast(
|
||||||
|
getParamValue(resolvedSearchParams, "error"),
|
||||||
|
getParamValue(resolvedSearchParams, "status"),
|
||||||
|
);
|
||||||
|
const planningMeter = calculatePlanningMeterSnapshot(
|
||||||
|
planningPageData.activities,
|
||||||
|
checkInStatus?.todayCheckIn?.dailyBudget ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell contentClassName="space-y-8">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||||
|
|
||||||
|
<PageIntro
|
||||||
|
eyebrow="Planning"
|
||||||
|
title="Plan vandaag bewust klein"
|
||||||
|
description="Voeg alleen activiteiten toe die vandaag echt relevant zijn. Houd de lijst licht, zodat je later goed kunt bijsturen zonder druk op te bouwen."
|
||||||
|
aside={
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Terug naar dashboard
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<ActivityForm
|
||||||
|
categories={planningPageData.categories}
|
||||||
|
activities={planningPageData.activities}
|
||||||
|
suggestions={planningPageData.suggestions}
|
||||||
|
dailyBudget={checkInStatus?.todayCheckIn?.dailyBudget ?? null}
|
||||||
|
/>
|
||||||
|
<AdHocActivityForm
|
||||||
|
categories={planningPageData.categories}
|
||||||
|
activities={planningPageData.activities}
|
||||||
|
suggestions={planningPageData.suggestions}
|
||||||
|
dailyBudget={checkInStatus?.todayCheckIn?.dailyBudget ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="space-y-5">
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Vandaag
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-lg text-foreground">
|
||||||
|
{planningPageData.activities.length === 0
|
||||||
|
? "Start met een eerste activiteit"
|
||||||
|
: `${planningPageData.activities.length} activiteiten in beeld`}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Lokale datum: {planningPageData.activityDate} in timezone `{planningPageData.timezone}`.
|
||||||
|
</CardDescription>
|
||||||
|
{checkInStatus?.todayCheckIn ? (
|
||||||
|
<CardDescription className="mt-3 text-sm leading-7 text-muted-foreground">
|
||||||
|
Je check-in van vandaag staat klaar met een dagbudget van{" "}
|
||||||
|
{checkInStatus.todayCheckIn.dailyBudget} punten. Zowel geplande als
|
||||||
|
ongeplande activiteiten lopen mee in je dagtotaal.
|
||||||
|
</CardDescription>
|
||||||
|
) : (
|
||||||
|
<CardDescription className="mt-3 text-sm leading-7 text-muted-foreground">
|
||||||
|
Er is nog geen ochtendcheck-in van vandaag. Je kunt wel alvast
|
||||||
|
activiteiten vastleggen, maar je budgetmeter blijft pas echt
|
||||||
|
betekenisvol zodra je check-in er staat.
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<EnergyMeterCard meter={planningMeter} />
|
||||||
|
|
||||||
|
<Card tone="primary" elevation="raised" className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
||||||
|
Bewuste grens
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 pb-6 text-sm leading-7 text-primary-foreground/90">
|
||||||
|
<p>Deze dagweergave blokkeert je niet en geeft bewust geen harde limieten.</p>
|
||||||
|
<p>De meter gebruikt een eenvoudige, uitlegbare afleiding uit duur en impact.</p>
|
||||||
|
<p>Ook ongeplande activiteiten tellen nu mee, zodat je dagbeeld dichter bij de werkelijkheid blijft.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DayOverviewCard overview={planningPageData.dayOverview} />
|
||||||
|
|
||||||
|
<TodayActivitiesList
|
||||||
|
activities={planningPageData.activities}
|
||||||
|
categories={planningPageData.categories}
|
||||||
|
skipReasons={planningPageData.skipReasons}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,30 +2,128 @@
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { buildPathWithQuery } from "@/lib/auth/navigation";
|
import { buildPathWithQuery } from "@/lib/auth/navigation";
|
||||||
|
import {
|
||||||
|
assertMaxLength,
|
||||||
|
FormDataValidationError,
|
||||||
|
getBooleanValue,
|
||||||
|
getEnumValue,
|
||||||
|
getOptionalString,
|
||||||
|
getOptionalTimeValue,
|
||||||
|
} from "@/lib/forms/parse";
|
||||||
|
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
||||||
|
import {
|
||||||
|
isAllowedProfileAvatarMimeType,
|
||||||
|
PROFILE_AVATAR_MAX_BYTES,
|
||||||
|
} from "@/lib/profile/avatar";
|
||||||
import { saveSettingsForCurrentUser } from "@/lib/profile/service";
|
import { saveSettingsForCurrentUser } from "@/lib/profile/service";
|
||||||
import type { SettingsSubmission } from "@/lib/profile/types";
|
import type { SettingsSubmission } from "@/lib/profile/types";
|
||||||
|
|
||||||
function getString(formData: FormData, key: string) {
|
const LOCALE_VALUES = ["nl-NL"] as const;
|
||||||
const value = formData.get(key);
|
const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value);
|
||||||
return typeof value === "string" ? value.trim() : "";
|
const MAX_DISPLAY_NAME_LENGTH = 80;
|
||||||
|
const MAX_TAGLINE_LENGTH = 160;
|
||||||
|
const MAX_BIO_LENGTH = 2000;
|
||||||
|
|
||||||
|
function getOptionalBoundedString(
|
||||||
|
formData: FormData,
|
||||||
|
key: string,
|
||||||
|
maximumLength: number,
|
||||||
|
errorCode: string,
|
||||||
|
) {
|
||||||
|
const value = getOptionalString(formData, key);
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assertMaxLength(value, maximumLength, errorCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBoolean(formData: FormData, key: string) {
|
function getOptionalAvatarFile(formData: FormData) {
|
||||||
return formData.get(key) === "true";
|
const value = formData.get("avatar");
|
||||||
|
|
||||||
|
if (!(value instanceof File) || value.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value.size > PROFILE_AVATAR_MAX_BYTES ||
|
||||||
|
!isAllowedProfileAvatarMimeType(value.type)
|
||||||
|
) {
|
||||||
|
throw new FormDataValidationError("invalid-avatar-file");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSettingsSubmission(formData: FormData): SettingsSubmission {
|
function buildSettingsSubmission(formData: FormData): SettingsSubmission {
|
||||||
|
const morningReminderEnabled = getBooleanValue(
|
||||||
|
formData,
|
||||||
|
"morningReminderEnabled",
|
||||||
|
"invalid-settings-input",
|
||||||
|
);
|
||||||
|
const reminderTime = getOptionalTimeValue(
|
||||||
|
formData,
|
||||||
|
"morningReminderTime",
|
||||||
|
"invalid-settings-input",
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale: getString(formData, "locale"),
|
displayName: getOptionalBoundedString(
|
||||||
timezone: getString(formData, "timezone"),
|
formData,
|
||||||
morningReminderEnabled: getBoolean(formData, "morningReminderEnabled"),
|
"displayName",
|
||||||
morningReminderTime: getString(formData, "morningReminderTime") || null,
|
MAX_DISPLAY_NAME_LENGTH,
|
||||||
reflectionReminderEnabled: getBoolean(formData, "reflectionReminderEnabled"),
|
"invalid-settings-input",
|
||||||
showEnergyPoints: getBoolean(formData, "showEnergyPoints"),
|
),
|
||||||
|
tagline: getOptionalBoundedString(
|
||||||
|
formData,
|
||||||
|
"tagline",
|
||||||
|
MAX_TAGLINE_LENGTH,
|
||||||
|
"invalid-settings-input",
|
||||||
|
),
|
||||||
|
bio: getOptionalBoundedString(
|
||||||
|
formData,
|
||||||
|
"bio",
|
||||||
|
MAX_BIO_LENGTH,
|
||||||
|
"invalid-settings-input",
|
||||||
|
),
|
||||||
|
avatarFile: getOptionalAvatarFile(formData),
|
||||||
|
locale: getEnumValue(formData, "locale", LOCALE_VALUES, "invalid-settings-input"),
|
||||||
|
timezone: getEnumValue(
|
||||||
|
formData,
|
||||||
|
"timezone",
|
||||||
|
ONBOARDING_TIMEZONE_VALUES,
|
||||||
|
"invalid-settings-input",
|
||||||
|
),
|
||||||
|
morningReminderEnabled,
|
||||||
|
morningReminderTime: morningReminderEnabled ? reminderTime : null,
|
||||||
|
reflectionReminderEnabled: getBooleanValue(
|
||||||
|
formData,
|
||||||
|
"reflectionReminderEnabled",
|
||||||
|
"invalid-settings-input",
|
||||||
|
),
|
||||||
|
showEnergyPoints: getBooleanValue(
|
||||||
|
formData,
|
||||||
|
"showEnergyPoints",
|
||||||
|
"invalid-settings-input",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSettingsAction(formData: FormData) {
|
export async function saveSettingsAction(
|
||||||
await saveSettingsForCurrentUser(buildSettingsSubmission(formData));
|
_previousState: null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<null> {
|
||||||
|
try {
|
||||||
|
await saveSettingsForCurrentUser(buildSettingsSubmission(formData));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormDataValidationError) {
|
||||||
|
redirect(buildPathWithQuery("/settings", { error: error.code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
redirect(buildPathWithQuery("/settings", { status: "saved" }));
|
redirect(buildPathWithQuery("/settings", { status: "saved" }));
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { signOutAction } from "@/app/auth-actions";
|
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||||
|
import { AppShell } from "@/components/navigation/app-shell";
|
||||||
|
import { PageIntro } from "@/components/navigation/page-intro";
|
||||||
|
import { ProfileAvatar } from "@/components/profile/profile-avatar";
|
||||||
import { SettingsForm } from "@/components/settings/settings-form";
|
import { SettingsForm } from "@/components/settings/settings-form";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -13,31 +14,16 @@ import {
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
import { getAuthState } from "@/lib/auth/session";
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { getSettingsStatusToast } from "@/lib/feedback/status-messages";
|
||||||
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||||
import { cn } from "@/lib/utils";
|
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type SettingsPageProps = {
|
type SettingsPageProps = {
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<PageSearchParams>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getParamValue(
|
|
||||||
params: Record<string, string | string[] | undefined>,
|
|
||||||
key: string,
|
|
||||||
) {
|
|
||||||
const value = params[key];
|
|
||||||
return typeof value === "string" ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSettingsNotice(status: string | null) {
|
|
||||||
if (status === "saved") {
|
|
||||||
return "Je instellingen zijn opgeslagen.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SettingsPage({ searchParams }: SettingsPageProps) {
|
export default async function SettingsPage({ searchParams }: SettingsPageProps) {
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
|
|
@ -60,7 +46,10 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
|
||||||
redirect("/onboarding");
|
redirect("/onboarding");
|
||||||
}
|
}
|
||||||
|
|
||||||
const notice = getSettingsNotice(getParamValue(resolvedSearchParams, "status"));
|
const statusToast = getSettingsStatusToast(
|
||||||
|
getParamValue(resolvedSearchParams, "error"),
|
||||||
|
getParamValue(resolvedSearchParams, "status"),
|
||||||
|
);
|
||||||
const profileTitle =
|
const profileTitle =
|
||||||
profileBundle.profile.displayName ??
|
profileBundle.profile.displayName ??
|
||||||
profileBundle.profile.email ??
|
profileBundle.profile.email ??
|
||||||
|
|
@ -68,76 +57,66 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
|
||||||
"Ingelogde gebruiker";
|
"Ingelogde gebruiker";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
|
<AppShell contentClassName="space-y-8">
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
<div className="space-y-8">
|
||||||
<header className="flex flex-col gap-5 rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:flex-row sm:items-start sm:justify-between sm:p-8">
|
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
|
||||||
<Link href="/dashboard" className="transition hover:text-slate-900">
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
<span>/</span>
|
|
||||||
<span>Instellingen</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
|
|
||||||
Instellingen
|
|
||||||
</h1>
|
|
||||||
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-700">
|
|
||||||
Pas je basisvoorkeuren rustig aan. Alles blijft beperkt tot jouw eigen
|
|
||||||
account en de wellness-first scope van release 1.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<PageIntro
|
||||||
|
eyebrow="Instellingen"
|
||||||
|
title="Basisinstellingen voor jouw account"
|
||||||
|
description="Pas je basisvoorkeuren rustig aan. Alles blijft beperkt tot jouw eigen account en de wellness-first scope van release 1."
|
||||||
|
aside={
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className={cn(
|
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||||
buttonVariants({ variant: "outline", size: "lg" }),
|
|
||||||
"h-11 rounded-full px-5",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Terug naar dashboard
|
Terug naar dashboard
|
||||||
</Link>
|
</Link>
|
||||||
<form action={signOutAction}>
|
}
|
||||||
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
|
/>
|
||||||
Uitloggen
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{notice ? (
|
|
||||||
<Alert className="rounded-[1.5rem] border-emerald-200 bg-emerald-50 text-emerald-950 [&_svg]:text-emerald-700">
|
|
||||||
<AlertDescription className="leading-7 text-current">
|
|
||||||
{notice}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<SettingsForm profileBundle={profileBundle} />
|
<SettingsForm profileBundle={profileBundle} />
|
||||||
|
|
||||||
<aside className="space-y-5">
|
<aside className="space-y-5">
|
||||||
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
<Card className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Account
|
Account
|
||||||
</p>
|
</p>
|
||||||
<CardTitle className="text-lg text-slate-900">{profileTitle}</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-6">
|
<CardContent className="space-y-4 pb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ProfileAvatar
|
||||||
|
avatarUrl={profileBundle.profile.avatarUrl}
|
||||||
|
displayName={profileBundle.profile.displayName}
|
||||||
|
email={profileBundle.profile.email}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-lg text-foreground">{profileTitle}</CardTitle>
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{profileBundle.profile.tagline ?? "Nog geen 1-regelige profielregel."}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
E-mailadres: {profileBundle.profile.email ?? authState.email ?? "Onbekend"}
|
E-mailadres: {profileBundle.profile.email ?? authState.email ?? "Onbekend"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
{profileBundle.profile.bio ? (
|
||||||
|
<CardDescription className="whitespace-pre-line text-sm leading-7 text-muted-foreground">
|
||||||
|
{profileBundle.profile.bio}
|
||||||
|
</CardDescription>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
<Card className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Huidige status
|
Huidige status
|
||||||
</p>
|
</p>
|
||||||
<CardTitle className="text-lg text-slate-900">
|
<CardTitle className="text-lg text-foreground">
|
||||||
Onboarding {profileBundle.profile.onboardingCompleted ? "afgerond" : "later afronden"}
|
Onboarding {profileBundle.profile.onboardingCompleted ? "afgerond" : "later afronden"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -151,6 +130,6 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,23 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthNotice } from "@/components/auth/auth-notice";
|
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||||
import { AuthPanel } from "@/components/auth/auth-panel";
|
import { AuthPanel } from "@/components/auth/auth-panel";
|
||||||
import { signUpAction } from "@/app/auth-actions";
|
import { signUpAction } from "@/app/auth-actions";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getAuthNotice } from "@/lib/auth/messages";
|
|
||||||
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
|
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
import { getAuthState } from "@/lib/auth/session";
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { getAuthStatusToast } from "@/lib/feedback/status-messages";
|
||||||
|
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type SignUpPageProps = {
|
type SignUpPageProps = {
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<PageSearchParams>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getParamValue(
|
|
||||||
params: Record<string, string | string[] | undefined>,
|
|
||||||
key: string,
|
|
||||||
) {
|
|
||||||
const value = params[key];
|
|
||||||
return typeof value === "string" ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SignUpPage({ searchParams }: SignUpPageProps) {
|
export default async function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
|
|
@ -34,7 +27,7 @@ export default async function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||||
redirect(next);
|
redirect(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notice = getAuthNotice(
|
const statusToast = getAuthStatusToast(
|
||||||
getParamValue(resolvedSearchParams, "error"),
|
getParamValue(resolvedSearchParams, "error"),
|
||||||
getParamValue(resolvedSearchParams, "status"),
|
getParamValue(resolvedSearchParams, "status"),
|
||||||
);
|
);
|
||||||
|
|
@ -48,16 +41,16 @@ export default async function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||||
footer={
|
footer={
|
||||||
<p>
|
<p>
|
||||||
Heb je al een account?{" "}
|
Heb je al een account?{" "}
|
||||||
<Link href={loginHref} className="font-semibold text-emerald-900">
|
<Link href={loginHref} className="font-semibold text-primary underline-offset-4 hover:underline">
|
||||||
Log dan in
|
Log dan in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AuthNotice notice={notice} />
|
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||||
|
|
||||||
{!authState.isConfigured ? (
|
{!authState.isConfigured ? (
|
||||||
<Alert className="rounded-[1.5rem] border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700">
|
<Alert variant="info">
|
||||||
<AlertDescription className="leading-7 text-current">
|
<AlertDescription className="leading-7 text-current">
|
||||||
Voeg eerst je Supabase-gegevens toe in `.env.local` op basis van `.env.example`.
|
Voeg eerst je Supabase-gegevens toe in `.env.local` op basis van `.env.example`.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|
@ -67,7 +60,7 @@ export default async function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||||
<input type="hidden" name="next" value={next} />
|
<input type="hidden" name="next" value={next} />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email" className="text-slate-800">
|
<Label htmlFor="email" className="text-foreground">
|
||||||
E-mailadres
|
E-mailadres
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -81,7 +74,7 @@ export default async function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-slate-800">
|
<Label htmlFor="password" className="text-foreground">
|
||||||
Wachtwoord
|
Wachtwoord
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
32
app/wizard-test/page.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AppShell } from "@/components/navigation/app-shell";
|
||||||
|
import { TestWizardFlow } from "@/components/wizard/test-wizard-flow";
|
||||||
|
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { isTestWizardEnabled } from "@/lib/config/feature-flags";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function WizardTestPage() {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
|
||||||
|
if (!isTestWizardEnabled()) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authState.isConfigured) {
|
||||||
|
redirect("/login?error=auth-not-configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/wizard-test"))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell contentClassName="space-y-8">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<TestWizardFlow />
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,16 +9,18 @@ type AuthNoticeProps = {
|
||||||
|
|
||||||
const toneStyles = {
|
const toneStyles = {
|
||||||
error: {
|
error: {
|
||||||
className: "mb-5 border-rose-200 bg-rose-50 text-rose-950 [&_svg]:text-rose-700",
|
variant: "destructive" as const,
|
||||||
|
className: "mb-5",
|
||||||
icon: AlertCircleIcon,
|
icon: AlertCircleIcon,
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
className:
|
variant: "success" as const,
|
||||||
"mb-5 border-emerald-200 bg-emerald-50 text-emerald-950 [&_svg]:text-emerald-700",
|
className: "mb-5",
|
||||||
icon: CheckCircle2Icon,
|
icon: CheckCircle2Icon,
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
className: "mb-5 border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700",
|
variant: "info" as const,
|
||||||
|
className: "mb-5",
|
||||||
icon: InfoIcon,
|
icon: InfoIcon,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -32,7 +34,7 @@ export function AuthNotice({ notice }: AuthNoticeProps) {
|
||||||
const Icon = tone.icon;
|
const Icon = tone.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert className={cn("rounded-[1.5rem] px-4 py-3", tone.className)}>
|
<Alert variant={tone.variant} className={cn("px-4 py-3", tone.className)}>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
<AlertDescription className="leading-7 text-current">
|
<AlertDescription className="leading-7 text-current">
|
||||||
{notice.text}
|
{notice.text}
|
||||||
|
|
|
||||||
|
|
@ -21,22 +21,22 @@ export function AuthPanel({
|
||||||
footer,
|
footer,
|
||||||
}: AuthPanelProps) {
|
}: AuthPanelProps) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
|
<main className="app-page">
|
||||||
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
<section className="flex flex-col justify-between rounded-[2rem] border border-black/10 bg-emerald-950 p-7 text-emerald-50 shadow-[0_18px_60px_rgba(6,78,59,0.18)] sm:p-9">
|
<section className="app-panel-primary flex flex-col justify-between rounded-[var(--radius-4xl)] p-7 sm:p-9">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-200/80">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/72">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-tight sm:text-5xl">
|
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-tight sm:text-5xl">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-5 max-w-xl text-base leading-8 text-emerald-50/85">
|
<p className="mt-5 max-w-xl text-base leading-8 text-primary-foreground/85">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 rounded-[1.5rem] border border-white/10 bg-white/8 p-5 text-sm leading-7 text-emerald-50/90">
|
<div className="mt-10 rounded-[var(--radius-2xl)] border border-white/10 bg-white/8 p-5 text-sm leading-7 text-primary-foreground/90">
|
||||||
<p className="font-semibold">Release 1 blijft bewust licht.</p>
|
<p className="font-semibold">Release 1 blijft bewust licht.</p>
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 space-y-2">
|
||||||
<li>Wellness-first en alleen voor individuele gebruikers</li>
|
<li>Wellness-first en alleen voor individuele gebruikers</li>
|
||||||
|
|
@ -47,7 +47,7 @@ export function AuthPanel({
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex items-center">
|
<section className="flex items-center">
|
||||||
<Card className="w-full rounded-[2rem] border border-border/60 bg-card/90 py-0 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur">
|
<Card elevation="raised" className="w-full rounded-[var(--radius-4xl)] py-0 backdrop-blur">
|
||||||
<CardContent className="p-6 sm:p-8">
|
<CardContent className="p-6 sm:p-8">
|
||||||
<div className="mb-6 flex items-center justify-between gap-3">
|
<div className="mb-6 flex items-center justify-between gap-3">
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
57
components/check-in/check-in-card.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { formatEnergyLevelLabel } from "@/lib/check-in/budget";
|
||||||
|
import type { MorningCheckInRecord } from "@/lib/check-in/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type CheckInCardProps = {
|
||||||
|
todayCheckIn: MorningCheckInRecord | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatSleepQualityLabel(value: MorningCheckInRecord["sleepQuality"]) {
|
||||||
|
if (value === "goed") {
|
||||||
|
return "Goed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "matig") {
|
||||||
|
return "Matig";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Slecht";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckInCard({ todayCheckIn }: CheckInCardProps) {
|
||||||
|
const title = todayCheckIn ? "Vandaag ingevuld" : "Nog niet ingevuld";
|
||||||
|
const description = todayCheckIn
|
||||||
|
? `Energie ${todayCheckIn.energyScore}/10, slaap ${formatSleepQualityLabel(todayCheckIn.sleepQuality).toLowerCase()}, niveau ${formatEnergyLevelLabel(todayCheckIn.energyLevel).toLowerCase()}, budget ${todayCheckIn.dailyBudget} punten.`
|
||||||
|
: "Leg je energiestart en slaapkwaliteit van vandaag vast.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Ochtendcheck-in
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-lg text-foreground">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pb-6">
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</CardDescription>
|
||||||
|
<Link
|
||||||
|
href="/check-in"
|
||||||
|
className={cn(buttonVariants({ size: "lg" }), "h-11 rounded-full px-5")}
|
||||||
|
>
|
||||||
|
{todayCheckIn ? "Werk check-in bij" : "Start check-in"}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
components/check-in/check-in-form.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import { saveMorningCheckInAction } from "@/app/check-in/actions";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
deriveBudgetSnapshot,
|
||||||
|
formatEnergyLevelLabel,
|
||||||
|
} from "@/lib/check-in/budget";
|
||||||
|
import { ENERGY_SCORE_VALUES, SLEEP_QUALITY_OPTIONS } from "@/lib/check-in/options";
|
||||||
|
import type {
|
||||||
|
MorningCheckInRecord,
|
||||||
|
SleepQuality,
|
||||||
|
} from "@/lib/check-in/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type CheckInFormProps = {
|
||||||
|
todayCheckIn: MorningCheckInRecord | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEnergyScorePrompt(score: number | null) {
|
||||||
|
if (score === null) {
|
||||||
|
return "Kies hoe je energiestart vandaag voelt op een schaal van 1 tot 10.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score <= 3) {
|
||||||
|
return "Rustig aan is vandaag waarschijnlijk extra belangrijk.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score <= 7) {
|
||||||
|
return "Je start voelt gematigd; plan bewust en houd ruimte over.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Je start voelt relatief sterk; hou nog steeds een rustige marge aan.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckInForm({ todayCheckIn }: CheckInFormProps) {
|
||||||
|
const [, formAction, isPending] = useActionState(saveMorningCheckInAction, null);
|
||||||
|
const [energyScore, setEnergyScore] = useState<number | null>(
|
||||||
|
todayCheckIn?.energyScore ?? null,
|
||||||
|
);
|
||||||
|
const [sleepQuality, setSleepQuality] = useState<SleepQuality | null>(
|
||||||
|
todayCheckIn?.sleepQuality ?? null,
|
||||||
|
);
|
||||||
|
const predictedBudget = energyScore === null ? null : deriveBudgetSnapshot(energyScore);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-6" aria-busy={isPending}>
|
||||||
|
<input type="hidden" name="energyScore" value={energyScore ?? ""} />
|
||||||
|
<input type="hidden" name="sleepQuality" value={sleepQuality ?? ""} />
|
||||||
|
|
||||||
|
<Card elevation="raised" className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Ochtendcheck-in
|
||||||
|
</p>
|
||||||
|
<CardTitle className="font-[family-name:var(--font-display)] text-3xl text-foreground">
|
||||||
|
Hoe start je vandaag?
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||||
|
Houd deze check-in klein. Je legt alleen vast hoe je energie en slaap
|
||||||
|
vandaag voelen, zodat de volgende stories daarop kunnen voortbouwen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 pb-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label id="energy-score-group-label" className="text-sm font-semibold text-foreground">
|
||||||
|
Energiescore vandaag
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
|
||||||
|
{getEnergyScorePrompt(energyScore)}
|
||||||
|
</p>
|
||||||
|
{predictedBudget ? (
|
||||||
|
<p className="text-sm leading-7 text-foreground/80" aria-live="polite">
|
||||||
|
Voor vandaag geeft dit niveau <strong>{formatEnergyLevelLabel(predictedBudget.energyLevel).toLowerCase()}</strong> en een startbudget van{" "}
|
||||||
|
<strong>{predictedBudget.dailyBudget} punten</strong>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-5 gap-2 sm:grid-cols-10"
|
||||||
|
role="group"
|
||||||
|
aria-labelledby="energy-score-group-label"
|
||||||
|
>
|
||||||
|
{ENERGY_SCORE_VALUES.map((value) => {
|
||||||
|
const isSelected = energyScore === value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setEnergyScore(value)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
aria-label={`Energie ${value} van 10`}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isSelected ? "default" : "outline",
|
||||||
|
size: "lg",
|
||||||
|
}),
|
||||||
|
"h-12 rounded-[1rem] px-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label id="sleep-quality-group-label" className="text-sm font-semibold text-foreground">
|
||||||
|
Hoe voelde je slaap?
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Eén globale indruk is genoeg voor deze eerste release.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid gap-3 sm:grid-cols-3"
|
||||||
|
role="group"
|
||||||
|
aria-labelledby="sleep-quality-group-label"
|
||||||
|
>
|
||||||
|
{SLEEP_QUALITY_OPTIONS.map((option) => {
|
||||||
|
const isSelected = sleepQuality === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setSleepQuality(option.value)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[1.25rem] border px-4 py-4 text-left transition",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary !text-white shadow-[var(--shadow-2)]"
|
||||||
|
: "border-border/60 bg-background/80 text-foreground hover:border-primary/35",
|
||||||
|
isPending && "pointer-events-none opacity-70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-semibold">{option.label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-2 block text-sm leading-6",
|
||||||
|
isSelected ? "!text-white/85" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
|
||||||
|
{isPending
|
||||||
|
? "Je ochtendcheck-in wordt opgeslagen..."
|
||||||
|
: todayCheckIn
|
||||||
|
? "Je kunt de check-in van vandaag nog aanpassen. Budget en niveau worden dan opnieuw afgeleid."
|
||||||
|
: "Je vult voor vandaag één check-in in, die je later nog kunt aanpassen."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
disabled={isPending || energyScore === null || sleepQuality === null}
|
||||||
|
className="h-11 rounded-full px-5"
|
||||||
|
>
|
||||||
|
{isPending
|
||||||
|
? "Check-in opslaan..."
|
||||||
|
: todayCheckIn
|
||||||
|
? "Werk check-in bij"
|
||||||
|
: "Sla check-in op"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/feedback/status-toast-bridge.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import type { StatusToast } from "@/lib/feedback/status-messages";
|
||||||
|
import { showStatusToast } from "@/lib/feedback/toast";
|
||||||
|
|
||||||
|
type StatusToastBridgeProps = {
|
||||||
|
toast: StatusToast | null;
|
||||||
|
paramKeys?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusToastBridge({
|
||||||
|
toast,
|
||||||
|
paramKeys = ["status"],
|
||||||
|
}: StatusToastBridgeProps) {
|
||||||
|
const hasShownRef = useRef(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast || hasShownRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasShownRef.current = true;
|
||||||
|
showStatusToast(toast);
|
||||||
|
|
||||||
|
if (!pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const key of paramKeys) {
|
||||||
|
if (nextParams.has(key)) {
|
||||||
|
nextParams.delete(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname;
|
||||||
|
router.replace(nextUrl, { scroll: false });
|
||||||
|
}, [paramKeys, pathname, router, searchParams, toast]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
89
components/navigation/account-menu.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
CircleUserRoundIcon,
|
||||||
|
LogInIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
Settings2Icon,
|
||||||
|
UserPlusIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { signOutAction } from "@/app/auth-actions";
|
||||||
|
import type { AuthState } from "@/lib/auth/session";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
type AccountMenuProps = {
|
||||||
|
authState: AuthState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AccountMenu({ authState }: AccountMenuProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger aria-label="Account menu">
|
||||||
|
<CircleUserRoundIcon className="size-4" />
|
||||||
|
Account
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
{authState.isConfigured ? (
|
||||||
|
authState.isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="normal-case tracking-normal text-foreground">
|
||||||
|
{authState.email ?? "Ingelogde gebruiker"}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={<Link href="/settings" />}
|
||||||
|
>
|
||||||
|
<Settings2Icon className="size-4" />
|
||||||
|
Instellingen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<form action={signOutAction}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
nativeButton
|
||||||
|
render={<button type="submit" />}
|
||||||
|
>
|
||||||
|
<LogOutIcon className="size-4" />
|
||||||
|
Uitloggen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel>Niet ingelogd</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={<Link href="/login" />}
|
||||||
|
>
|
||||||
|
<LogInIcon className="size-4" />
|
||||||
|
Inloggen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={<Link href="/sign-up" />}
|
||||||
|
>
|
||||||
|
<UserPlusIcon className="size-4" />
|
||||||
|
Account aanmaken
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<LogInIcon className="size-4" />
|
||||||
|
Auth nog niet geconfigureerd
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/navigation/app-shell.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AppShellProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
contentClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function AppShell({
|
||||||
|
children,
|
||||||
|
contentClassName,
|
||||||
|
}: AppShellProps) {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app-page">
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8">
|
||||||
|
<TopNav authState={authState} />
|
||||||
|
<div className={cn("flex-1", contentClassName)}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
components/navigation/page-intro.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PageIntroProps = {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
aside?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageIntro({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
aside,
|
||||||
|
className,
|
||||||
|
}: PageIntroProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 rounded-[var(--radius-4xl)] border border-border/70 bg-card/72 p-6 shadow-[var(--shadow-1)] backdrop-blur sm:flex-row sm:items-end sm:justify-between sm:p-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-3xl text-base leading-8 text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{aside ? <div className="sm:pl-6">{aside}</div> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
components/navigation/theme-menu.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MoonStarIcon, MonitorCogIcon, SunMediumIcon } from "lucide-react";
|
||||||
|
import { useTheme } from "@/components/theme-provider";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
const themeOptions = [
|
||||||
|
{ value: "light", label: "Light", icon: SunMediumIcon },
|
||||||
|
{ value: "dark", label: "Dark", icon: MoonStarIcon },
|
||||||
|
{ value: "system", label: "System", icon: MonitorCogIcon },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ThemeMenu() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger aria-label="Thema kiezen">
|
||||||
|
<MonitorCogIcon className="size-4" />
|
||||||
|
Theme
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuLabel>Weergave</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup value={theme ?? "system"} onValueChange={setTheme}>
|
||||||
|
{themeOptions.map((option) => {
|
||||||
|
const OptionIcon = option.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
closeOnClick
|
||||||
|
>
|
||||||
|
<OptionIcon className="size-4" />
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
components/navigation/top-nav.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
ClipboardCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { AuthState } from "@/lib/auth/session";
|
||||||
|
import { AccountMenu } from "@/components/navigation/account-menu";
|
||||||
|
import { ThemeMenu } from "@/components/navigation/theme-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const primaryNavItems = [
|
||||||
|
{
|
||||||
|
href: "/",
|
||||||
|
label: "About",
|
||||||
|
icon: InfoIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard",
|
||||||
|
label: "Dashboard",
|
||||||
|
icon: LayoutDashboardIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/planning",
|
||||||
|
label: "Planning",
|
||||||
|
icon: ActivityIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/check-in",
|
||||||
|
label: "Check-in",
|
||||||
|
icon: ClipboardCheckIcon,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TopNavProps = {
|
||||||
|
authState: AuthState;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isActivePath(pathname: string, href: string) {
|
||||||
|
if (href === "/") {
|
||||||
|
return pathname === "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopNav({ authState }: TopNavProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-4 z-40">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 rounded-[var(--radius-4xl)] border border-border/70 bg-card/86 px-5 py-4 shadow-[var(--shadow-2)] backdrop-blur">
|
||||||
|
<Link href="/" className="shrink-0">
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
Inspannings Monitor
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-base font-semibold tracking-[-0.02em] text-foreground">
|
||||||
|
Wellness-first dagflow
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label="Hoofdnavigatie"
|
||||||
|
className="flex flex-1 flex-wrap items-center gap-2 md:ml-6"
|
||||||
|
>
|
||||||
|
{primaryNavItems.map((item) => {
|
||||||
|
const isActive = pathname ? isActivePath(pathname, item.href) : false;
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary !text-white shadow-[var(--shadow-1)] [&_svg]:!text-white"
|
||||||
|
: "text-muted-foreground hover:bg-secondary hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<ThemeMenu />
|
||||||
|
<AccountMenu authState={authState} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,49 +1,41 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MouseEvent } from "react";
|
import { useActionState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { completeOnboardingAction, skipOnboardingAction } from "@/app/onboarding/actions";
|
import { completeOnboardingAction, skipOnboardingAction } from "@/app/onboarding/actions";
|
||||||
|
import { OnboardingStepIntro } from "@/components/onboarding/onboarding-step-intro";
|
||||||
|
import { OnboardingStepPreferences } from "@/components/onboarding/onboarding-step-preferences";
|
||||||
|
import { OnboardingStepProfile } from "@/components/onboarding/onboarding-step-profile";
|
||||||
|
import { PreferenceHiddenFields } from "@/components/preferences/preference-hidden-fields";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { WizardShell } from "@/components/wizard/wizard-shell";
|
||||||
Card,
|
import { useOnboardingDraft, type OnboardingDraft } from "@/lib/onboarding/use-onboarding-draft";
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
|
||||||
import type { ProfileBundle } from "@/lib/profile/types";
|
import type { ProfileBundle } from "@/lib/profile/types";
|
||||||
|
import { useWizardFlow } from "@/lib/wizard/use-wizard-flow";
|
||||||
|
import type { WizardStepDefinition } from "@/lib/wizard/types";
|
||||||
|
|
||||||
type OnboardingFlowProps = {
|
type OnboardingFlowProps = {
|
||||||
profileBundle: ProfileBundle;
|
profileBundle: ProfileBundle;
|
||||||
};
|
};
|
||||||
|
|
||||||
const steps = [
|
const steps: WizardStepDefinition<OnboardingDraft>[] = [
|
||||||
{
|
{
|
||||||
|
id: "intro",
|
||||||
eyebrow: "Stap 1",
|
eyebrow: "Stap 1",
|
||||||
title: "Zo gebruiken we Inspannings Monitor",
|
title: "Zo gebruiken we Inspannings Monitor",
|
||||||
description:
|
description:
|
||||||
"De app helpt je om je dag rustiger te plannen en terug te kijken zonder medische claims of zorgverlenerfuncties.",
|
"De app helpt je om je dag rustiger te plannen en terug te kijken zonder medische claims of zorgverlenerfuncties.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "profile",
|
||||||
eyebrow: "Stap 2",
|
eyebrow: "Stap 2",
|
||||||
title: "Basisprofiel",
|
title: "Basisprofiel",
|
||||||
description:
|
description:
|
||||||
"Kies hoe de app je mag aanspreken en welke timezone het best bij je dagindeling past.",
|
"Kies hoe de app je mag aanspreken en welke timezone het best bij je dagindeling past.",
|
||||||
|
canContinue: (draft) => draft.timezone.length > 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "preferences",
|
||||||
eyebrow: "Stap 3",
|
eyebrow: "Stap 3",
|
||||||
title: "Startvoorkeuren",
|
title: "Startvoorkeuren",
|
||||||
description:
|
description:
|
||||||
|
|
@ -51,292 +43,133 @@ const steps = [
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
function renderCurrentStep(
|
||||||
|
stepId: string,
|
||||||
|
draft: OnboardingDraft,
|
||||||
|
updateDraft: (patch: Partial<OnboardingDraft>) => void,
|
||||||
|
disabled: boolean,
|
||||||
|
) {
|
||||||
|
switch (stepId) {
|
||||||
|
case "intro":
|
||||||
|
return <OnboardingStepIntro />;
|
||||||
|
case "profile":
|
||||||
|
return (
|
||||||
|
<OnboardingStepProfile
|
||||||
|
draft={draft}
|
||||||
|
updateDraft={updateDraft}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "preferences":
|
||||||
|
return (
|
||||||
|
<OnboardingStepPreferences
|
||||||
|
draft={draft}
|
||||||
|
updateDraft={updateDraft}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function OnboardingFlow({ profileBundle }: OnboardingFlowProps) {
|
export function OnboardingFlow({ profileBundle }: OnboardingFlowProps) {
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [, completeFormAction, isCompleting] = useActionState(completeOnboardingAction, null);
|
||||||
const [displayName, setDisplayName] = useState(profileBundle.profile.displayName ?? "");
|
const [, skipFormAction, isSkipping] = useActionState(skipOnboardingAction, null);
|
||||||
const [timezone, setTimezone] = useState(profileBundle.profile.timezone);
|
const { draft, updateDraft } = useOnboardingDraft(profileBundle);
|
||||||
const [showEnergyPoints, setShowEnergyPoints] = useState(
|
const wizard = useWizardFlow({
|
||||||
profileBundle.settings.showEnergyPoints,
|
steps,
|
||||||
);
|
draft,
|
||||||
const [morningReminderEnabled, setMorningReminderEnabled] = useState(
|
});
|
||||||
profileBundle.settings.morningReminderEnabled,
|
const isPending = isCompleting || isSkipping;
|
||||||
);
|
|
||||||
const [morningReminderTime, setMorningReminderTime] = useState(
|
const aside = (
|
||||||
profileBundle.settings.morningReminderTime ?? "08:30",
|
<Alert className="rounded-[var(--radius-2xl)] border-white/10 bg-white/8 text-primary-foreground [&_svg]:text-primary-foreground/80">
|
||||||
);
|
<AlertDescription className="leading-7 text-current">
|
||||||
const [reflectionReminderEnabled, setReflectionReminderEnabled] = useState(
|
<span className="block font-semibold">Release 1 blijft bewust wellness-first.</span>
|
||||||
profileBundle.settings.reflectionReminderEnabled,
|
<span className="mt-2 block">
|
||||||
|
Alleen voor individuele gebruikers, zonder delen of zorgverlenerstoegang.
|
||||||
|
</span>
|
||||||
|
<span className="block">
|
||||||
|
De app geeft geen diagnose, behandeling of medisch advies.
|
||||||
|
</span>
|
||||||
|
<span className="block">
|
||||||
|
Bij acute of snel verslechterende klachten hoort directe hulp via arts, huisartsenpost of 112 buiten deze app.
|
||||||
|
</span>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
);
|
);
|
||||||
|
|
||||||
const step = steps[currentStep];
|
const topAction = (
|
||||||
const isFirstStep = currentStep === 0;
|
<>
|
||||||
const isLastStep = currentStep === steps.length - 1;
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Korte onboarding
|
||||||
|
</p>
|
||||||
|
<form action={skipFormAction}>
|
||||||
|
<Button type="submit" variant="outline" disabled={isPending} className="rounded-full">
|
||||||
|
{isSkipping ? "Overslaan..." : "Nu overslaan"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
function goToPreviousStep() {
|
const backAction = (
|
||||||
setCurrentStep((stepIndex) => Math.max(0, stepIndex - 1));
|
<Button
|
||||||
}
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={wizard.goToPreviousStep}
|
||||||
|
disabled={wizard.isFirstStep || isPending}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
Vorige
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
function goToNextStep(event: MouseEvent<HTMLButtonElement>) {
|
const nextAction = wizard.isLastStep ? (
|
||||||
event.preventDefault();
|
<Button
|
||||||
setCurrentStep((stepIndex) => Math.min(steps.length - 1, stepIndex + 1));
|
key="complete-onboarding"
|
||||||
}
|
type="submit"
|
||||||
|
form="onboarding-form"
|
||||||
|
disabled={isPending}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
{isCompleting ? "Onboarding opslaan..." : "Rond onboarding af"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key={`next-step-${wizard.currentStep.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={wizard.goToNextStep}
|
||||||
|
disabled={!wizard.canContinue || isPending}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
Ga verder
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
<WizardShell
|
||||||
<section className="rounded-[2rem] border border-primary/15 bg-primary p-7 text-primary-foreground shadow-[0_18px_60px_rgba(22,58,43,0.18)] sm:p-9">
|
eyebrow={wizard.currentStep.eyebrow}
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/70">
|
title={wizard.currentStep.title}
|
||||||
{step.eyebrow}
|
description={wizard.currentStep.description}
|
||||||
</p>
|
progressCurrent={wizard.currentStepIndex + 1}
|
||||||
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-tight sm:text-5xl">
|
progressTotal={wizard.steps.length}
|
||||||
{step.title}
|
topAction={topAction}
|
||||||
</h1>
|
aside={aside}
|
||||||
<p className="mt-5 max-w-xl text-base leading-8 text-primary-foreground/85">
|
backAction={backAction}
|
||||||
{step.description}
|
nextAction={nextAction}
|
||||||
</p>
|
>
|
||||||
|
<form
|
||||||
|
id="onboarding-form"
|
||||||
|
action={completeFormAction}
|
||||||
|
className="space-y-6"
|
||||||
|
aria-busy={isPending}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="displayName" value={draft.displayName} />
|
||||||
|
<PreferenceHiddenFields draft={draft} />
|
||||||
|
|
||||||
<Alert className="mt-10 rounded-[1.5rem] border-white/10 bg-white/8 text-primary-foreground [&_svg]:text-primary-foreground/80">
|
{renderCurrentStep(wizard.currentStep.id, draft, updateDraft, isPending)}
|
||||||
<AlertDescription className="leading-7 text-current">
|
</form>
|
||||||
<span className="block font-semibold">Release 1 blijft bewust wellness-first.</span>
|
</WizardShell>
|
||||||
<span className="mt-2 block">
|
|
||||||
Alleen voor individuele gebruikers, zonder delen of zorgverlenerstoegang.
|
|
||||||
</span>
|
|
||||||
<span className="block">
|
|
||||||
De app geeft geen diagnose, behandeling of medisch advies.
|
|
||||||
</span>
|
|
||||||
<span className="block">
|
|
||||||
Bij acute of snel verslechterende klachten hoort directe hulp via arts, huisartsenpost of 112 buiten deze app.
|
|
||||||
</span>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<ol className="mt-8 flex gap-3">
|
|
||||||
{steps.map((item, index) => (
|
|
||||||
<li
|
|
||||||
key={item.title}
|
|
||||||
className={`h-2 flex-1 rounded-full ${
|
|
||||||
index <= currentStep ? "bg-primary-foreground/85" : "bg-white/15"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-border/60 bg-card/90 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:p-8">
|
|
||||||
<div className="mb-6 flex items-center justify-between gap-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
|
||||||
Korte onboarding
|
|
||||||
</p>
|
|
||||||
<form action={skipOnboardingAction}>
|
|
||||||
<Button type="submit" variant="outline" className="rounded-full">
|
|
||||||
Nu overslaan
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form action={completeOnboardingAction} className="space-y-6">
|
|
||||||
<input type="hidden" name="displayName" value={displayName} />
|
|
||||||
<input type="hidden" name="timezone" value={timezone} />
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="showEnergyPoints"
|
|
||||||
value={showEnergyPoints ? "true" : "false"}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="morningReminderEnabled"
|
|
||||||
value={morningReminderEnabled ? "true" : "false"}
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="morningReminderTime" value={morningReminderTime} />
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="reflectionReminderEnabled"
|
|
||||||
value={reflectionReminderEnabled ? "true" : "false"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{currentStep === 0 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
|
|
||||||
<CardHeader className="pb-0">
|
|
||||||
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
|
||||||
Wat je hier wél krijgt
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
|
||||||
Een rustige plan-doe-evalueer flow met energiebudgetten, zonder
|
|
||||||
druk, score-oordeel of medische terminologie.
|
|
||||||
</CardDescription>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
|
|
||||||
<CardHeader className="pb-0">
|
|
||||||
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
|
||||||
Wat deze app niet doet
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
|
||||||
Geen diagnose, geen behandeling, geen medische triage en geen
|
|
||||||
automatisch delen met derden.
|
|
||||||
</CardDescription>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentStep === 1 ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="display-name" className="text-slate-800">
|
|
||||||
Schermnaam
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="display-name"
|
|
||||||
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base md:text-base"
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(event) => setDisplayName(event.target.value)}
|
|
||||||
placeholder="Optioneel, bijvoorbeeld Jan"
|
|
||||||
maxLength={40}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert className="rounded-[1.5rem] border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700">
|
|
||||||
<AlertDescription className="leading-7 text-current">
|
|
||||||
Voertaal voor release 1 staat vast op <strong>Nederlands</strong>.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-slate-800">Timezone</Label>
|
|
||||||
<Select
|
|
||||||
value={timezone}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setTimezone(value ?? profileBundle.profile.timezone)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
|
|
||||||
<SelectValue placeholder="Kies een timezone" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ONBOARDING_TIMEZONE_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentStep === 2 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
|
|
||||||
<CardContent className="flex items-start justify-between gap-4 py-5">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-sm font-semibold text-slate-900">
|
|
||||||
Toon energiebudgetpunten
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm leading-7 text-muted-foreground">
|
|
||||||
Laat geplande en resterende punten zichtbaar zien in de interface.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch checked={showEnergyPoints} onCheckedChange={setShowEnergyPoints} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
|
|
||||||
<CardContent className="space-y-4 py-5">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-sm font-semibold text-slate-900">
|
|
||||||
Zet een lichte ochtendreminder aan
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm leading-7 text-muted-foreground">
|
|
||||||
Handig als je later een korte check-in wilt doen zonder extra druk.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={morningReminderEnabled}
|
|
||||||
onCheckedChange={setMorningReminderEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{morningReminderEnabled ? (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="morning-reminder-time" className="text-slate-800">
|
|
||||||
Tijdstip voor de ochtendreminder
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="morning-reminder-time"
|
|
||||||
className="h-12 rounded-[1.25rem] bg-white px-4 text-base md:text-base"
|
|
||||||
type="time"
|
|
||||||
value={morningReminderTime}
|
|
||||||
onChange={(event) => setMorningReminderTime(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
|
|
||||||
<CardContent className="flex items-start justify-between gap-4 py-5">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-sm font-semibold text-slate-900">
|
|
||||||
Sta lichte reflectieprompts toe
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm leading-7 text-muted-foreground">
|
|
||||||
Optionele terugblikprompts kunnen later helpen om rustiger patronen te zien.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={reflectionReminderEnabled}
|
|
||||||
onCheckedChange={setReflectionReminderEnabled}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={goToPreviousStep}
|
|
||||||
disabled={isFirstStep}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
Vorige
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{isLastStep ? (
|
|
||||||
<Button
|
|
||||||
key="complete-onboarding"
|
|
||||||
type="submit"
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
Rond onboarding af
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
key={`next-step-${currentStep}`}
|
|
||||||
type="button"
|
|
||||||
onClick={goToNextStep}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
Ga verder
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
components/onboarding/onboarding-step-intro.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function OnboardingStepIntro() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
||||||
|
Wat je hier wél krijgt
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Een rustige plan-doe-evalueer flow met energiebudgetten, zonder druk,
|
||||||
|
score-oordeel of medische terminologie.
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
||||||
|
Wat deze app niet doet
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Geen diagnose, geen behandeling, geen medische triage en geen automatisch
|
||||||
|
delen met derden.
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
components/onboarding/onboarding-step-preferences.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import type { OnboardingDraft } from "@/lib/onboarding/use-onboarding-draft";
|
||||||
|
|
||||||
|
type OnboardingStepPreferencesProps = {
|
||||||
|
draft: OnboardingDraft;
|
||||||
|
updateDraft: (patch: Partial<OnboardingDraft>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OnboardingStepPreferences({
|
||||||
|
draft,
|
||||||
|
updateDraft,
|
||||||
|
disabled = false,
|
||||||
|
}: OnboardingStepPreferencesProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
|
<CardContent className="flex items-start justify-between gap-4 py-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-sm font-semibold text-foreground">
|
||||||
|
Toon energiebudgetpunten
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Laat geplande en resterende punten zichtbaar zien in de interface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
disabled={disabled}
|
||||||
|
checked={draft.showEnergyPoints}
|
||||||
|
onCheckedChange={(checked) => updateDraft({ showEnergyPoints: checked })}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
|
<CardContent className="space-y-4 py-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-sm font-semibold text-foreground">
|
||||||
|
Zet een lichte ochtendreminder aan
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Handig als je later een korte check-in wilt doen zonder extra druk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
disabled={disabled}
|
||||||
|
checked={draft.morningReminderEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateDraft({ morningReminderEnabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.morningReminderEnabled ? (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="morning-reminder-time" className="text-foreground">
|
||||||
|
Tijdstip voor de ochtendreminder
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="morning-reminder-time"
|
||||||
|
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base md:text-base"
|
||||||
|
disabled={disabled}
|
||||||
|
type="time"
|
||||||
|
value={draft.morningReminderTime}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDraft({ morningReminderTime: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
|
<CardContent className="flex items-start justify-between gap-4 py-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-sm font-semibold text-foreground">
|
||||||
|
Sta lichte reflectieprompts toe
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Optionele terugblikprompts kunnen later helpen om rustiger patronen te zien.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
disabled={disabled}
|
||||||
|
checked={draft.reflectionReminderEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateDraft({ reflectionReminderEnabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
components/onboarding/onboarding-step-profile.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
||||||
|
import type { OnboardingDraft } from "@/lib/onboarding/use-onboarding-draft";
|
||||||
|
|
||||||
|
type OnboardingStepProfileProps = {
|
||||||
|
draft: OnboardingDraft;
|
||||||
|
updateDraft: (patch: Partial<OnboardingDraft>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OnboardingStepProfile({
|
||||||
|
draft,
|
||||||
|
updateDraft,
|
||||||
|
disabled = false,
|
||||||
|
}: OnboardingStepProfileProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="display-name" className="text-foreground">
|
||||||
|
Schermnaam
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="display-name"
|
||||||
|
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base md:text-base"
|
||||||
|
disabled={disabled}
|
||||||
|
type="text"
|
||||||
|
value={draft.displayName}
|
||||||
|
onChange={(event) => updateDraft({ displayName: event.target.value })}
|
||||||
|
placeholder="Optioneel, bijvoorbeeld Jan"
|
||||||
|
maxLength={40}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert variant="info">
|
||||||
|
<AlertDescription className="leading-7 text-current">
|
||||||
|
Voertaal voor release 1 staat vast op <strong>Nederlands</strong>.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-foreground">Timezone</Label>
|
||||||
|
<Select
|
||||||
|
disabled={disabled}
|
||||||
|
value={draft.timezone}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateDraft({
|
||||||
|
timezone: value ?? draft.timezone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
|
||||||
|
<SelectValue placeholder="Kies een timezone" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ONBOARDING_TIMEZONE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
components/planning/activity-evaluation-fields.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import { saveActivityEvaluationAction } from "@/app/planning/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import type { ActivityStatus, SkipReason } from "@/lib/planning/types";
|
||||||
|
|
||||||
|
type ActivityEvaluationFieldsProps = {
|
||||||
|
activityId: string;
|
||||||
|
status: ActivityStatus;
|
||||||
|
skipReasons: SkipReason[];
|
||||||
|
initialSkipReasonId: string | null;
|
||||||
|
initialNotes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActivityEvaluationFields({
|
||||||
|
activityId,
|
||||||
|
status,
|
||||||
|
skipReasons,
|
||||||
|
initialSkipReasonId,
|
||||||
|
initialNotes,
|
||||||
|
}: ActivityEvaluationFieldsProps) {
|
||||||
|
const [, formAction, isPending] = useActionState(saveActivityEvaluationAction, null);
|
||||||
|
const [skipReasonId, setSkipReasonId] = useState(
|
||||||
|
initialSkipReasonId ?? skipReasons[0]?.id ?? "",
|
||||||
|
);
|
||||||
|
const [notes, setNotes] = useState(initialNotes ?? "");
|
||||||
|
const selectedSkipReason =
|
||||||
|
skipReasons.find((skipReason) => skipReason.id === skipReasonId) ?? null;
|
||||||
|
|
||||||
|
if (status !== "skipped" && status !== "adjusted") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4" aria-busy={isPending}>
|
||||||
|
<input type="hidden" name="activityId" value={activityId} />
|
||||||
|
{status === "skipped" ? (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name="skipReasonId" value={skipReasonId} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold text-foreground">
|
||||||
|
Waarom is deze activiteit overgeslagen?
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
disabled={isPending}
|
||||||
|
value={skipReasonId}
|
||||||
|
onValueChange={(value) => setSkipReasonId(value ?? skipReasons[0]?.id ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-11 w-full rounded-[1.15rem] bg-background/80 px-4 text-sm">
|
||||||
|
<SelectValue placeholder="Kies een skip-reden">
|
||||||
|
{selectedSkipReason?.labelNl}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{skipReasons.map((skipReason) => (
|
||||||
|
<SelectItem key={skipReason.id} value={skipReason.id}>
|
||||||
|
{skipReason.labelNl}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`activity-notes-${activityId}`} className="text-sm font-semibold text-foreground">
|
||||||
|
Extra toelichting
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id={`activity-notes-${activityId}`}
|
||||||
|
name="notes"
|
||||||
|
className="min-h-24 w-full rounded-[1.15rem] border border-input bg-background/80 px-4 py-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
disabled={isPending}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="Optioneel: wat speelde mee?"
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => setNotes(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`activity-notes-${activityId}`} className="text-sm font-semibold text-foreground">
|
||||||
|
Wat heb je aangepast?
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id={`activity-notes-${activityId}`}
|
||||||
|
name="notes"
|
||||||
|
className="min-h-28 w-full rounded-[1.15rem] border border-input bg-background/80 px-4 py-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
disabled={isPending}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="Beschrijf kort wat je hebt aangepast aan duur, vorm of intensiteit."
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => setNotes(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-xs leading-6 text-muted-foreground" aria-live="polite">
|
||||||
|
{isPending
|
||||||
|
? "Evaluatie wordt opgeslagen..."
|
||||||
|
: status === "skipped"
|
||||||
|
? "Voeg optioneel context toe, zodat later duidelijker is waarom deze activiteit niet doorging."
|
||||||
|
: "Beschrijf kort wat je hebt aangepast, zodat dagreflectie later betekenisvoller wordt."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
(status === "skipped" && !skipReasonId) ||
|
||||||
|
(status === "adjusted" && !notes.trim())
|
||||||
|
}
|
||||||
|
className="rounded-full px-4"
|
||||||
|
>
|
||||||
|
{isPending ? "Evaluatie opslaan..." : "Evaluatie opslaan"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
366
components/planning/activity-form.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useMemo, useState } from "react";
|
||||||
|
import { createActivityAction } from "@/app/planning/actions";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { ActivitySuggestionList } from "@/components/planning/activity-suggestion-list";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
ACTIVITY_DURATION_SUGGESTIONS,
|
||||||
|
ACTIVITY_IMPACT_OPTIONS,
|
||||||
|
ACTIVITY_PRIORITY_OPTIONS,
|
||||||
|
} from "@/lib/planning/form-options";
|
||||||
|
import { calculatePlanningMeterSnapshot, deriveActivityEnergyPoints } from "@/lib/planning/meter";
|
||||||
|
import type { ActivityCategory, ActivityRecord, ActivitySuggestion } from "@/lib/planning/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ActivityFormProps = {
|
||||||
|
categories: ActivityCategory[];
|
||||||
|
activities: ActivityRecord[];
|
||||||
|
suggestions: ActivitySuggestion[];
|
||||||
|
dailyBudget: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActivityForm({
|
||||||
|
categories,
|
||||||
|
activities,
|
||||||
|
suggestions,
|
||||||
|
dailyBudget,
|
||||||
|
}: ActivityFormProps) {
|
||||||
|
const [, formAction, isPending] = useActionState(createActivityAction, null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [categoryId, setCategoryId] = useState<string>(categories[0]?.id ?? "");
|
||||||
|
const [durationMinutes, setDurationMinutes] = useState("30");
|
||||||
|
const [impactLevel, setImpactLevel] = useState<"laag" | "midden" | "hoog">("midden");
|
||||||
|
const [priorityLevel, setPriorityLevel] = useState<"laag" | "normaal" | "hoog">("normaal");
|
||||||
|
|
||||||
|
const selectedCategory = useMemo(
|
||||||
|
() => categories.find((category) => category.id === categoryId) ?? null,
|
||||||
|
[categories, categoryId],
|
||||||
|
);
|
||||||
|
const currentMeter = useMemo(
|
||||||
|
() => calculatePlanningMeterSnapshot(activities, dailyBudget),
|
||||||
|
[activities, dailyBudget],
|
||||||
|
);
|
||||||
|
const previewPoints = useMemo(() => {
|
||||||
|
const parsedDuration = Number.parseInt(durationMinutes, 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsedDuration) || parsedDuration <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deriveActivityEnergyPoints({
|
||||||
|
durationMinutes: parsedDuration,
|
||||||
|
impactLevel,
|
||||||
|
status: "planned",
|
||||||
|
});
|
||||||
|
}, [durationMinutes, impactLevel]);
|
||||||
|
const previewMeter = useMemo(() => {
|
||||||
|
if (previewPoints === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculatePlanningMeterSnapshot(
|
||||||
|
[
|
||||||
|
...activities,
|
||||||
|
{
|
||||||
|
durationMinutes: Number.parseInt(durationMinutes, 10),
|
||||||
|
impactLevel,
|
||||||
|
status: "planned",
|
||||||
|
} as ActivityRecord,
|
||||||
|
],
|
||||||
|
dailyBudget,
|
||||||
|
);
|
||||||
|
}, [activities, dailyBudget, durationMinutes, impactLevel, previewPoints]);
|
||||||
|
|
||||||
|
function applySuggestion(suggestion: ActivitySuggestion) {
|
||||||
|
setName(suggestion.name);
|
||||||
|
setCategoryId(suggestion.categoryId);
|
||||||
|
setDurationMinutes(String(suggestion.durationMinutes));
|
||||||
|
setImpactLevel(suggestion.impactLevel);
|
||||||
|
setPriorityLevel(suggestion.priorityLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-6" aria-busy={isPending}>
|
||||||
|
<input type="hidden" name="categoryId" value={categoryId} />
|
||||||
|
<input type="hidden" name="impactLevel" value={impactLevel} />
|
||||||
|
<input type="hidden" name="priorityLevel" value={priorityLevel} />
|
||||||
|
|
||||||
|
<Card elevation="raised" className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Dagplanning
|
||||||
|
</p>
|
||||||
|
<CardTitle className="font-[family-name:var(--font-display)] text-3xl text-foreground">
|
||||||
|
Plan een activiteit voor vandaag
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||||
|
Houd het klein en concreet. Je legt alleen de basis vast: wat je wilt doen,
|
||||||
|
hoe lang het ongeveer duurt en hoe zwaar het aanvoelt.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 pb-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="activity-name" className="text-foreground">
|
||||||
|
Naam van de activiteit
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="activity-name"
|
||||||
|
name="name"
|
||||||
|
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base"
|
||||||
|
disabled={isPending}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Bijvoorbeeld: was opvouwen"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<ActivitySuggestionList
|
||||||
|
categories={categories}
|
||||||
|
suggestions={suggestions}
|
||||||
|
query={name}
|
||||||
|
disabled={isPending}
|
||||||
|
onSelect={applySuggestion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-foreground">Categorie</Label>
|
||||||
|
<Select
|
||||||
|
disabled={isPending}
|
||||||
|
value={categoryId}
|
||||||
|
onValueChange={(value) => setCategoryId(value ?? categories[0]?.id ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
|
||||||
|
<SelectValue placeholder="Kies een categorie">
|
||||||
|
{selectedCategory?.labelNl}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category.id} value={category.id}>
|
||||||
|
{category.labelNl}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedCategory ? (
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Gekozen categorie: <strong>{selectedCategory.labelNl}</strong>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="duration-minutes" className="text-foreground">
|
||||||
|
Geschatte duur in minuten
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="duration-minutes"
|
||||||
|
name="durationMinutes"
|
||||||
|
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base"
|
||||||
|
disabled={isPending}
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={720}
|
||||||
|
step={1}
|
||||||
|
type="number"
|
||||||
|
value={durationMinutes}
|
||||||
|
onChange={(event) => setDurationMinutes(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap gap-2"
|
||||||
|
role="group"
|
||||||
|
aria-label="Snelle duurkeuzes"
|
||||||
|
>
|
||||||
|
{ACTIVITY_DURATION_SUGGESTIONS.map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setDurationMinutes(String(value))}
|
||||||
|
aria-pressed={durationMinutes === String(value)}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: durationMinutes === String(value) ? "default" : "outline",
|
||||||
|
size: "sm",
|
||||||
|
}),
|
||||||
|
"rounded-full px-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value} min
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
|
<CardContent className="space-y-2 py-5">
|
||||||
|
<p className="text-sm font-semibold text-foreground">Vooruitblik op de meter</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
|
||||||
|
{previewPoints === null
|
||||||
|
? "Kies een geldige duur en impact om te zien hoeveel punten deze activiteit ongeveer toevoegt."
|
||||||
|
: `Deze activiteit telt voorlopig voor ${previewPoints} punten. Je totaal zou dan uitkomen op ${previewMeter?.totalPoints ?? currentMeter.totalPoints} punten in beeld.`}
|
||||||
|
</p>
|
||||||
|
{dailyBudget !== null && previewMeter ? (
|
||||||
|
<p className="text-sm leading-7 text-foreground/80" aria-live="polite">
|
||||||
|
Dat is {previewMeter.dailyBudget} punten budget, met daarna nog{" "}
|
||||||
|
<strong>{previewMeter.remainingBudget} punten ruimte</strong>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{previewMeter?.isOverBudget ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTitle className="text-sm">Niet-blokkerende waarschuwing</AlertTitle>
|
||||||
|
<AlertDescription className="leading-7 text-current">
|
||||||
|
Met deze activiteit kom je ongeveer{" "}
|
||||||
|
<strong>{Math.abs(previewMeter.remainingBudget ?? 0)} punten</strong> boven je dagbudget uit.
|
||||||
|
Je kunt nog steeds opslaan, maar dit is een goed moment om bewust te heroverwegen of te versimpelen.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label id="impact-group-label" className="text-sm font-semibold text-foreground">
|
||||||
|
Verwachte impact
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Kies hoe belastend deze activiteit voor jou aanvoelt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="grid gap-3"
|
||||||
|
role="group"
|
||||||
|
aria-labelledby="impact-group-label"
|
||||||
|
>
|
||||||
|
{ACTIVITY_IMPACT_OPTIONS.map((option) => {
|
||||||
|
const isSelected = impactLevel === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setImpactLevel(option.value)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[1.25rem] border px-4 py-4 text-left transition",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary !text-white shadow-[var(--shadow-2)]"
|
||||||
|
: "border-border/60 bg-background/80 text-foreground hover:border-primary/35",
|
||||||
|
isPending && "pointer-events-none opacity-70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-semibold">{option.label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-2 block text-sm leading-6",
|
||||||
|
isSelected ? "!text-white/85" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label id="priority-group-label" className="text-sm font-semibold text-foreground">
|
||||||
|
Prioriteit voor vandaag
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Dit helpt straks om bewust te herschikken zonder alles te verliezen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="grid gap-3"
|
||||||
|
role="group"
|
||||||
|
aria-labelledby="priority-group-label"
|
||||||
|
>
|
||||||
|
{ACTIVITY_PRIORITY_OPTIONS.map((option) => {
|
||||||
|
const isSelected = priorityLevel === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setPriorityLevel(option.value)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[1.25rem] border px-4 py-4 text-left transition",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary !text-white shadow-[var(--shadow-2)]"
|
||||||
|
: "border-border/60 bg-background/80 text-foreground hover:border-primary/35",
|
||||||
|
isPending && "pointer-events-none opacity-70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-semibold">{option.label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-2 block text-sm leading-6",
|
||||||
|
isSelected ? "!text-white/85" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
|
||||||
|
{isPending
|
||||||
|
? "Je activiteit wordt opgeslagen..."
|
||||||
|
: "Je activiteit wordt vandaag toegevoegd met status `gepland`, waarna je dagtotaal direct opnieuw wordt berekend."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
!name.trim() ||
|
||||||
|
!categoryId ||
|
||||||
|
!durationMinutes.trim()
|
||||||
|
}
|
||||||
|
className="h-11 rounded-full px-5"
|
||||||
|
>
|
||||||
|
{isPending ? "Activiteit opslaan..." : "Plan activiteit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
components/planning/activity-status-actions.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { updateActivityStatusAction } from "@/app/planning/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ActivityStatus } from "@/lib/planning/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ActivityStatusActionsProps = {
|
||||||
|
activityId: string;
|
||||||
|
status: ActivityStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions: Array<{
|
||||||
|
value: ActivityStatus;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "planned", label: "Gepland" },
|
||||||
|
{ value: "completed", label: "Uitgevoerd" },
|
||||||
|
{ value: "skipped", label: "Overgeslagen" },
|
||||||
|
{ value: "adjusted", label: "Aangepast" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ActivityStatusActions({
|
||||||
|
activityId,
|
||||||
|
status,
|
||||||
|
}: ActivityStatusActionsProps) {
|
||||||
|
const [, formAction, isPending] = useActionState(updateActivityStatusAction, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-3" aria-busy={isPending}>
|
||||||
|
<input type="hidden" name="activityId" value={activityId} />
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap gap-2"
|
||||||
|
role="group"
|
||||||
|
aria-label="Status van deze activiteit wijzigen"
|
||||||
|
>
|
||||||
|
{statusOptions.map((option) => {
|
||||||
|
const isCurrent = option.value === status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="submit"
|
||||||
|
name="status"
|
||||||
|
value={option.value}
|
||||||
|
size="sm"
|
||||||
|
variant={isCurrent ? "default" : "outline"}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-pressed={isCurrent}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3",
|
||||||
|
isPending && "pointer-events-none opacity-70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-6 text-muted-foreground" aria-live="polite">
|
||||||
|
{isPending
|
||||||
|
? "Status wordt opgeslagen..."
|
||||||
|
: "Je kunt de status vandaag direct aanpassen zonder de activiteit te verwijderen."}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
components/planning/activity-suggestion-list.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { ActivityCategory, ActivitySuggestion } from "@/lib/planning/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ActivitySuggestionListProps = {
|
||||||
|
categories: ActivityCategory[];
|
||||||
|
suggestions: ActivitySuggestion[];
|
||||||
|
query: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
showPriority?: boolean;
|
||||||
|
onSelect: (suggestion: ActivitySuggestion) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeQuery(value: string) {
|
||||||
|
return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("nl-NL");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryLabel(categories: ActivityCategory[], categoryId: string) {
|
||||||
|
return categories.find((category) => category.id === categoryId)?.labelNl ?? "Onbekende categorie";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImpactLabel(value: ActivitySuggestion["impactLevel"]) {
|
||||||
|
if (value === "laag") {
|
||||||
|
return "Lage impact";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "hoog") {
|
||||||
|
return "Hoge impact";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Middenimpact";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPriorityLabel(value: ActivitySuggestion["priorityLevel"]) {
|
||||||
|
if (value === "laag") {
|
||||||
|
return "Lage prioriteit";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "hoog") {
|
||||||
|
return "Hoge prioriteit";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Normale prioriteit";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivitySuggestionList({
|
||||||
|
categories,
|
||||||
|
suggestions,
|
||||||
|
query,
|
||||||
|
disabled = false,
|
||||||
|
showPriority = true,
|
||||||
|
onSelect,
|
||||||
|
}: ActivitySuggestionListProps) {
|
||||||
|
const normalizedQuery = normalizeQuery(query);
|
||||||
|
const visibleSuggestions = useMemo(() => {
|
||||||
|
const filtered = normalizedQuery
|
||||||
|
? suggestions.filter((suggestion) =>
|
||||||
|
normalizeQuery(suggestion.name).includes(normalizedQuery),
|
||||||
|
)
|
||||||
|
: suggestions;
|
||||||
|
|
||||||
|
return filtered.slice(0, normalizedQuery ? 5 : 4);
|
||||||
|
}, [normalizedQuery, suggestions]);
|
||||||
|
|
||||||
|
if (visibleSuggestions.length === 0) {
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Geen eerdere match gevonden. Je kunt deze activiteit gewoon nieuw opslaan.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{normalizedQuery ? "Vergelijkbare eerdere activiteiten" : "Snel hergebruiken"}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{visibleSuggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion.id}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onSelect(suggestion)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[1.1rem] border border-border/65 bg-background/78 px-4 py-3 text-left transition hover:border-primary/35 hover:bg-card disabled:pointer-events-none disabled:opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-semibold text-foreground">{suggestion.name}</span>
|
||||||
|
<span className="mt-1 block text-sm leading-6 text-muted-foreground">
|
||||||
|
{getCategoryLabel(categories, suggestion.categoryId)} · {suggestion.durationMinutes} min ·{" "}
|
||||||
|
{formatImpactLabel(suggestion.impactLevel)}
|
||||||
|
{showPriority ? ` · ${formatPriorityLabel(suggestion.priorityLevel)}` : ""}
|
||||||
|
{suggestion.useCount > 1 ? ` · ${suggestion.useCount}× gebruikt` : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
302
components/planning/ad-hoc-activity-form.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useMemo, useState } from "react";
|
||||||
|
import { createAdHocActivityAction } from "@/app/planning/actions";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { ActivitySuggestionList } from "@/components/planning/activity-suggestion-list";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
ACTIVITY_DURATION_SUGGESTIONS,
|
||||||
|
ACTIVITY_IMPACT_OPTIONS,
|
||||||
|
} from "@/lib/planning/form-options";
|
||||||
|
import {
|
||||||
|
calculatePlanningMeterSnapshot,
|
||||||
|
deriveActivityEnergyPoints,
|
||||||
|
} from "@/lib/planning/meter";
|
||||||
|
import type { ActivityCategory, ActivityRecord, ActivitySuggestion } from "@/lib/planning/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AdHocActivityFormProps = {
|
||||||
|
categories: ActivityCategory[];
|
||||||
|
activities: ActivityRecord[];
|
||||||
|
suggestions: ActivitySuggestion[];
|
||||||
|
dailyBudget: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdHocActivityForm({
|
||||||
|
categories,
|
||||||
|
activities,
|
||||||
|
suggestions,
|
||||||
|
dailyBudget,
|
||||||
|
}: AdHocActivityFormProps) {
|
||||||
|
const [, formAction, isPending] = useActionState(createAdHocActivityAction, null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [categoryId, setCategoryId] = useState<string>(categories[0]?.id ?? "");
|
||||||
|
const [durationMinutes, setDurationMinutes] = useState("30");
|
||||||
|
const [impactLevel, setImpactLevel] = useState<"laag" | "midden" | "hoog">("midden");
|
||||||
|
|
||||||
|
const selectedCategory = useMemo(
|
||||||
|
() => categories.find((category) => category.id === categoryId) ?? null,
|
||||||
|
[categories, categoryId],
|
||||||
|
);
|
||||||
|
const currentMeter = useMemo(
|
||||||
|
() => calculatePlanningMeterSnapshot(activities, dailyBudget),
|
||||||
|
[activities, dailyBudget],
|
||||||
|
);
|
||||||
|
const previewPoints = useMemo(() => {
|
||||||
|
const parsedDuration = Number.parseInt(durationMinutes, 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsedDuration) || parsedDuration <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deriveActivityEnergyPoints({
|
||||||
|
durationMinutes: parsedDuration,
|
||||||
|
impactLevel,
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
|
}, [durationMinutes, impactLevel]);
|
||||||
|
const previewMeter = useMemo(() => {
|
||||||
|
if (previewPoints === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculatePlanningMeterSnapshot(
|
||||||
|
[
|
||||||
|
...activities,
|
||||||
|
{
|
||||||
|
durationMinutes: Number.parseInt(durationMinutes, 10),
|
||||||
|
impactLevel,
|
||||||
|
status: "completed",
|
||||||
|
} as ActivityRecord,
|
||||||
|
],
|
||||||
|
dailyBudget,
|
||||||
|
);
|
||||||
|
}, [activities, dailyBudget, durationMinutes, impactLevel, previewPoints]);
|
||||||
|
|
||||||
|
function applySuggestion(suggestion: ActivitySuggestion) {
|
||||||
|
setName(suggestion.name);
|
||||||
|
setCategoryId(suggestion.categoryId);
|
||||||
|
setDurationMinutes(String(suggestion.durationMinutes));
|
||||||
|
setImpactLevel(suggestion.impactLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-6" aria-busy={isPending}>
|
||||||
|
<input type="hidden" name="categoryId" value={categoryId} />
|
||||||
|
<input type="hidden" name="impactLevel" value={impactLevel} />
|
||||||
|
|
||||||
|
<Card tone="subtle" className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Ongepland
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-2xl text-foreground">
|
||||||
|
Voeg iets toe dat vandaag spontaan gebeurde
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||||
|
Gebruik dit voor activiteiten die niet vooraf gepland waren, maar wel
|
||||||
|
onderdeel zijn geworden van je echte dag. Ze worden opgeslagen als
|
||||||
|
<strong> ongepland en uitgevoerd</strong>.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 pb-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ad-hoc-activity-name" className="text-foreground">
|
||||||
|
Naam van de ongeplande activiteit
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ad-hoc-activity-name"
|
||||||
|
name="name"
|
||||||
|
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base"
|
||||||
|
disabled={isPending}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Bijvoorbeeld: onverwacht telefoontje of extra boodschap"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<ActivitySuggestionList
|
||||||
|
categories={categories}
|
||||||
|
suggestions={suggestions}
|
||||||
|
query={name}
|
||||||
|
disabled={isPending}
|
||||||
|
showPriority={false}
|
||||||
|
onSelect={applySuggestion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-foreground">Categorie</Label>
|
||||||
|
<Select
|
||||||
|
disabled={isPending}
|
||||||
|
value={categoryId}
|
||||||
|
onValueChange={(value) => setCategoryId(value ?? categories[0]?.id ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
|
||||||
|
<SelectValue placeholder="Kies een categorie">
|
||||||
|
{selectedCategory?.labelNl}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category.id} value={category.id}>
|
||||||
|
{category.labelNl}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedCategory ? (
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Gekozen categorie: <strong>{selectedCategory.labelNl}</strong>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ad-hoc-duration-minutes" className="text-foreground">
|
||||||
|
Geschatte duur in minuten
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ad-hoc-duration-minutes"
|
||||||
|
name="durationMinutes"
|
||||||
|
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base"
|
||||||
|
disabled={isPending}
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={720}
|
||||||
|
step={1}
|
||||||
|
type="number"
|
||||||
|
value={durationMinutes}
|
||||||
|
onChange={(event) => setDurationMinutes(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2" role="group" aria-label="Snelle duurkeuzes">
|
||||||
|
{ACTIVITY_DURATION_SUGGESTIONS.map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setDurationMinutes(String(value))}
|
||||||
|
aria-pressed={durationMinutes === String(value)}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: durationMinutes === String(value) ? "default" : "outline",
|
||||||
|
size: "sm",
|
||||||
|
}),
|
||||||
|
"rounded-full px-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value} min
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label id="ad-hoc-impact-group-label" className="text-sm font-semibold text-foreground">
|
||||||
|
Ervaren impact
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Kies hoe belastend deze onverwachte activiteit achteraf aanvoelde.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3" role="group" aria-labelledby="ad-hoc-impact-group-label">
|
||||||
|
{ACTIVITY_IMPACT_OPTIONS.map((option) => {
|
||||||
|
const isSelected = impactLevel === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setImpactLevel(option.value)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[1.25rem] border px-4 py-4 text-left transition",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary !text-white shadow-[var(--shadow-2)]"
|
||||||
|
: "border-border/60 bg-background/80 text-foreground hover:border-primary/35",
|
||||||
|
isPending && "pointer-events-none opacity-70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-semibold">{option.label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-2 block text-sm leading-6",
|
||||||
|
isSelected ? "!text-white/85" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
|
<CardContent className="space-y-2 py-5">
|
||||||
|
<p className="text-sm font-semibold text-foreground">Effect op je dagtotaal</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
|
||||||
|
{previewPoints === null
|
||||||
|
? "Kies een geldige duur en impact om te zien hoeveel punten deze ongeplande activiteit ongeveer toevoegt."
|
||||||
|
: `Deze activiteit telt voorlopig voor ${previewPoints} punten. Je dagtotaal zou dan uitkomen op ${previewMeter?.totalPoints ?? currentMeter.totalPoints} punten.`}
|
||||||
|
</p>
|
||||||
|
{dailyBudget !== null && previewMeter ? (
|
||||||
|
<p className="text-sm leading-7 text-foreground/80" aria-live="polite">
|
||||||
|
Dat is {previewMeter.dailyBudget} punten budget, met daarna nog{" "}
|
||||||
|
<strong>{previewMeter.remainingBudget} punten ruimte</strong>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{previewMeter?.isOverBudget ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTitle className="text-sm">Niet-blokkerende waarschuwing</AlertTitle>
|
||||||
|
<AlertDescription className="leading-7 text-current">
|
||||||
|
Met deze ongeplande activiteit kom je ongeveer{" "}
|
||||||
|
<strong>{Math.abs(previewMeter.remainingBudget ?? 0)} punten</strong> boven je dagbudget uit.
|
||||||
|
Je kunt nog steeds opslaan, maar dit helpt je later beter begrijpen waarom je dag zwaarder uitviel.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
|
||||||
|
{isPending
|
||||||
|
? "Je ongeplande activiteit wordt opgeslagen..."
|
||||||
|
: "Deze activiteit wordt vandaag toegevoegd met bron `ongepland` en status `uitgevoerd`."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
disabled={isPending || !name.trim() || !categoryId || !durationMinutes.trim()}
|
||||||
|
className="h-11 rounded-full px-5"
|
||||||
|
>
|
||||||
|
{isPending ? "Ongeplande activiteit opslaan..." : "Voeg ongeplande activiteit toe"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
components/planning/day-overview-card.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import type { DayOverviewSnapshot } from "@/lib/planning/day-overview";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type DayOverviewCardProps = {
|
||||||
|
overview: DayOverviewSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDifferenceCopy(overview: DayOverviewSnapshot) {
|
||||||
|
if (overview.totalActivities === 0) {
|
||||||
|
return "Je dagoverzicht vult zich zodra je activiteiten toevoegt en evalueert.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overview.executedActivityCount === 0) {
|
||||||
|
return "Er is nog niets als uitgevoerd of aangepast gemarkeerd. Je oorspronkelijke plan staat wel al klaar.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overview.pointDifference > 0) {
|
||||||
|
return `Je werkelijke dag kwam ${overview.pointDifference} punten boven je oorspronkelijke plan uit.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overview.pointDifference < 0) {
|
||||||
|
return `Je werkelijke dag bleef ${Math.abs(overview.pointDifference)} punten onder je oorspronkelijke plan.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Je werkelijke dag lag qua punten precies in lijn met je oorspronkelijke plan.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusAccentClassName(key: "planned" | "completed" | "adjusted" | "skipped") {
|
||||||
|
if (key === "completed") {
|
||||||
|
return "bg-success text-white";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "adjusted") {
|
||||||
|
return "bg-primary text-white";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "skipped") {
|
||||||
|
return "bg-warning text-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "bg-secondary text-secondary-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DayOverviewCard({ overview }: DayOverviewCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Dagoverzicht
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-lg text-foreground">Gepland versus werkelijk</CardTitle>
|
||||||
|
<CardDescription className="max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||||
|
Dit overzicht laat zien wat je vooraf van plan was, wat er echt gebeurde en hoe de statussen van vandaag verdeeld zijn.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5 pb-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Card tone="subtle" size="sm" className="py-0">
|
||||||
|
<CardContent className="space-y-1 py-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Vooraf gepland
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-semibold tracking-[-0.03em] text-foreground">
|
||||||
|
{overview.plannedActivityCount}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{overview.plannedPoints} punten oorspronkelijk in plan
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card tone="subtle" size="sm" className="py-0">
|
||||||
|
<CardContent className="space-y-1 py-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Werkelijk gedaan
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-semibold tracking-[-0.03em] text-foreground">
|
||||||
|
{overview.executedActivityCount}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{overview.actualPoints} punten uitgevoerd of aangepast
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card tone="subtle" size="sm" className="py-0">
|
||||||
|
<CardContent className="space-y-1 py-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Ongepland erbij
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-semibold tracking-[-0.03em] text-foreground">
|
||||||
|
{overview.adHocActivityCount}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Activiteiten die onderweg aan je dag zijn toegevoegd
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card tone="subtle" size="sm" className="py-0">
|
||||||
|
<CardContent className="space-y-1 py-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Nog open
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-semibold tracking-[-0.03em] text-foreground">
|
||||||
|
{overview.openActivityCount}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Activiteiten die nog op `gepland` staan
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[var(--radius-xl)] border border-border/65 bg-background/72 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
key: "planned" as const,
|
||||||
|
label: "Gepland",
|
||||||
|
value: overview.openActivityCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "completed" as const,
|
||||||
|
label: "Uitgevoerd",
|
||||||
|
value: overview.completedActivityCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "adjusted" as const,
|
||||||
|
label: "Aangepast",
|
||||||
|
value: overview.adjustedActivityCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "skipped" as const,
|
||||||
|
label: "Overgeslagen",
|
||||||
|
value: overview.skippedActivityCount,
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.key}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em]",
|
||||||
|
getStatusAccentClassName(item.key),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<span className="rounded-full bg-black/12 px-1.5 py-0.5 text-[0.7rem] font-semibold text-current">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-muted-foreground">
|
||||||
|
{getDifferenceCopy(overview)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
components/planning/energy-meter-card.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import type { PlanningMeterSnapshot } from "@/lib/planning/meter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type EnergyMeterCardProps = {
|
||||||
|
meter: PlanningMeterSnapshot;
|
||||||
|
tone?: "default" | "subtle";
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRemainingLabel(remainingBudget: number) {
|
||||||
|
if (remainingBudget > 0) {
|
||||||
|
return `${remainingBudget} punten over`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingBudget === 0) {
|
||||||
|
return "Geen punten over";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.abs(remainingBudget)} punten erboven`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeterDescription(meter: PlanningMeterSnapshot) {
|
||||||
|
if (meter.dailyBudget === null) {
|
||||||
|
return "Er is nog geen dagbudget beschikbaar. De meter wordt actief zodra je ochtendcheck-in van vandaag er staat.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meter.isOverBudget) {
|
||||||
|
return "Je dagtotaal zit boven je dagbudget. Dat is een signaal om eventueel iets te verschuiven of lichter te maken, niet om te blokkeren.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "De meter blijft bewust eenvoudig: punten volgen uit duur en impact van alle activiteiten die vandaag in beeld zijn.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnergyMeterCard({
|
||||||
|
meter,
|
||||||
|
tone = "default",
|
||||||
|
}: EnergyMeterCardProps) {
|
||||||
|
const progressValue =
|
||||||
|
meter.dailyBudget === null ? null : Math.min(100, Math.max(0, meter.progressPercent ?? 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card tone={tone === "default" ? "default" : "subtle"} className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
EnergyMeter
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-lg text-foreground">
|
||||||
|
{meter.dailyBudget === null
|
||||||
|
? `${meter.totalPoints} punten in beeld`
|
||||||
|
: `${meter.totalPoints} van ${meter.dailyBudget} punten in beeld`}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pb-6">
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{getMeterDescription(meter)}
|
||||||
|
</CardDescription>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div
|
||||||
|
className="h-3 overflow-hidden rounded-full bg-secondary"
|
||||||
|
role={progressValue === null ? undefined : "progressbar"}
|
||||||
|
aria-label="Voortgang van je dagbudget"
|
||||||
|
aria-valuemin={progressValue === null ? undefined : 0}
|
||||||
|
aria-valuemax={progressValue === null ? undefined : 100}
|
||||||
|
aria-valuenow={progressValue === null ? undefined : progressValue}
|
||||||
|
aria-valuetext={
|
||||||
|
meter.dailyBudget === null
|
||||||
|
? "Nog geen dagbudget beschikbaar"
|
||||||
|
: `${meter.totalPoints} van ${meter.dailyBudget} punten in beeld`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full transition-[width]",
|
||||||
|
meter.isOverBudget ? "bg-warning" : "bg-primary",
|
||||||
|
)}
|
||||||
|
style={{ width: `${meter.progressPercent ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 text-sm leading-7 text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<strong>Activiteiten:</strong> {meter.activityCount}
|
||||||
|
</p>
|
||||||
|
{meter.dailyBudget !== null ? (
|
||||||
|
<p>
|
||||||
|
<strong>Resterend:</strong> {formatRemainingLabel(meter.remainingBudget ?? 0)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meter.dailyBudget !== null && meter.isOverBudget ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTitle className="text-sm">Je zit boven je dagbudget</AlertTitle>
|
||||||
|
<AlertDescription className="leading-7 text-current">
|
||||||
|
Je dagtotaal komt nu <strong>{Math.abs(meter.remainingBudget ?? 0)} punten</strong> boven het dagbudget uit.
|
||||||
|
Je kunt nog steeds doorgaan, maar dit is een goed moment om iets te schrappen, te verkorten of later te doen.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
components/planning/today-activities-list.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { ActivityEvaluationFields } from "@/components/planning/activity-evaluation-fields";
|
||||||
|
import { ActivityStatusActions } from "@/components/planning/activity-status-actions";
|
||||||
|
import { deriveActivityEnergyPoints } from "@/lib/planning/meter";
|
||||||
|
import type {
|
||||||
|
ActivityCategory,
|
||||||
|
ActivityRecord,
|
||||||
|
SkipReason,
|
||||||
|
} from "@/lib/planning/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type TodayActivitiesListProps = {
|
||||||
|
activities: ActivityRecord[];
|
||||||
|
categories: ActivityCategory[];
|
||||||
|
skipReasons: SkipReason[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCategoryLabel(categories: ActivityCategory[], categoryId: string) {
|
||||||
|
return categories.find((category) => category.id === categoryId)?.labelNl ?? "Onbekende categorie";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImpactLabel(value: ActivityRecord["impactLevel"]) {
|
||||||
|
if (value === "laag") {
|
||||||
|
return "Laag";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "midden") {
|
||||||
|
return "Midden";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Hoog";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPriorityLabel(value: ActivityRecord["priorityLevel"]) {
|
||||||
|
if (value === "laag") {
|
||||||
|
return "Laag";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "hoog") {
|
||||||
|
return "Hoog";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Normaal";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatusLabel(value: ActivityRecord["status"]) {
|
||||||
|
if (value === "completed") {
|
||||||
|
return "Uitgevoerd";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "skipped") {
|
||||||
|
return "Overgeslagen";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "adjusted") {
|
||||||
|
return "Aangepast";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Gepland";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClassName(value: ActivityRecord["status"]) {
|
||||||
|
if (value === "completed") {
|
||||||
|
return "bg-success text-primary-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "skipped") {
|
||||||
|
return "bg-warning text-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "adjusted") {
|
||||||
|
return "bg-secondary text-secondary-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "bg-secondary text-secondary-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSourceLabel(value: ActivityRecord["source"]) {
|
||||||
|
if (value === "ad_hoc") {
|
||||||
|
return "Ongepland";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Gepland";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceBadgeClassName(value: ActivityRecord["source"]) {
|
||||||
|
if (value === "ad_hoc") {
|
||||||
|
return "bg-primary !text-white";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayActivitiesList({
|
||||||
|
activities,
|
||||||
|
categories,
|
||||||
|
skipReasons,
|
||||||
|
}: TodayActivitiesListProps) {
|
||||||
|
return (
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Vandaag in beeld
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-lg text-foreground">
|
||||||
|
{activities.length === 0
|
||||||
|
? "Nog geen activiteiten toegevoegd"
|
||||||
|
: `${activities.length} ${activities.length === 1 ? "activiteit" : "activiteiten"}`}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pb-6">
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Je dag is nog leeg. Plan eerst iets kleins of voeg later een ongeplande activiteit toe als je dag anders liep dan verwacht.
|
||||||
|
</CardDescription>
|
||||||
|
) : (
|
||||||
|
activities.map((activity) => (
|
||||||
|
<div
|
||||||
|
key={activity.id}
|
||||||
|
className="rounded-[var(--radius-xl)] border border-border/60 bg-background/80 px-4 py-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">{activity.name}</p>
|
||||||
|
<p className="mt-1 text-sm leading-7 text-muted-foreground">
|
||||||
|
{getCategoryLabel(categories, activity.categoryId)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em]",
|
||||||
|
getSourceBadgeClassName(activity.source),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatSourceLabel(activity.source)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em]",
|
||||||
|
getStatusBadgeClassName(activity.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatStatusLabel(activity.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 text-sm leading-7 text-foreground/80 sm:grid-cols-3">
|
||||||
|
<p>
|
||||||
|
<strong>Duur:</strong> {activity.durationMinutes} min
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Impact:</strong> {formatImpactLabel(activity.impactLevel)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Prioriteit:</strong> {formatPriorityLabel(activity.priorityLevel)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Punten:</strong>{" "}
|
||||||
|
{deriveActivityEnergyPoints(activity)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 border-t border-border/60 pt-4">
|
||||||
|
<ActivityStatusActions
|
||||||
|
activityId={activity.id}
|
||||||
|
status={activity.status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(activity.status === "skipped" || activity.status === "adjusted") ? (
|
||||||
|
<div className="mt-4 rounded-[var(--radius-lg)] border border-border/60 bg-card/60 p-4">
|
||||||
|
<ActivityEvaluationFields
|
||||||
|
key={`${activity.id}-${activity.status}`}
|
||||||
|
activityId={activity.id}
|
||||||
|
status={activity.status}
|
||||||
|
skipReasons={skipReasons}
|
||||||
|
initialSkipReasonId={activity.skipReasonId}
|
||||||
|
initialNotes={activity.notes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
components/preferences/preference-hidden-fields.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { PreferenceDraft } from "@/lib/preferences/use-preferences-draft";
|
||||||
|
|
||||||
|
type PreferenceHiddenFieldsProps = {
|
||||||
|
draft: PreferenceDraft;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PreferenceHiddenFields({
|
||||||
|
draft,
|
||||||
|
}: PreferenceHiddenFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name="timezone" value={draft.timezone} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="showEnergyPoints"
|
||||||
|
value={draft.showEnergyPoints ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="morningReminderEnabled"
|
||||||
|
value={draft.morningReminderEnabled ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="morningReminderTime" value={draft.morningReminderTime} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="reflectionReminderEnabled"
|
||||||
|
value={draft.reflectionReminderEnabled ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
components/profile/profile-avatar.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ProfileAvatarProps = {
|
||||||
|
avatarUrl: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const avatarSizeClasses = {
|
||||||
|
sm: "size-12 text-sm",
|
||||||
|
md: "size-16 text-base",
|
||||||
|
lg: "size-20 text-xl",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function getProfileInitials(displayName: string | null, email?: string | null) {
|
||||||
|
const source = displayName?.trim() || email?.trim() || "";
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return "IM";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = source
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "IM";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileAvatar({
|
||||||
|
avatarUrl,
|
||||||
|
displayName,
|
||||||
|
email = null,
|
||||||
|
size = "md",
|
||||||
|
className,
|
||||||
|
}: ProfileAvatarProps) {
|
||||||
|
const initials = getProfileInitials(displayName, email);
|
||||||
|
const label = displayName || email || "Profielavatar";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/70 bg-muted/70 font-semibold tracking-[0.08em] text-foreground shadow-[var(--shadow-1)]",
|
||||||
|
avatarSizeClasses[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${avatarUrl})` }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className={cn("relative z-10", avatarUrl ? "sr-only" : null)}>{initials}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
import { saveSettingsAction } from "@/app/settings/actions";
|
import { saveSettingsAction } from "@/app/settings/actions";
|
||||||
|
import { PreferenceHiddenFields } from "@/components/preferences/preference-hidden-fields";
|
||||||
|
import { ProfileAvatar } from "@/components/profile/profile-avatar";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,7 +24,10 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
||||||
|
import { PROFILE_AVATAR_MAX_BYTES } from "@/lib/profile/avatar";
|
||||||
|
import { usePreferenceDraft } from "@/lib/preferences/use-preferences-draft";
|
||||||
import type { ProfileBundle } from "@/lib/profile/types";
|
import type { ProfileBundle } from "@/lib/profile/types";
|
||||||
|
|
||||||
type SettingsFormProps = {
|
type SettingsFormProps = {
|
||||||
|
|
@ -37,48 +42,30 @@ const LOCALE_OPTIONS = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
|
const [, formAction, isPending] = useActionState(saveSettingsAction, null);
|
||||||
const [locale, setLocale] = useState(profileBundle.profile.locale);
|
const [locale, setLocale] = useState(profileBundle.profile.locale);
|
||||||
const [timezone, setTimezone] = useState(profileBundle.profile.timezone);
|
const { draft, updateDraft } = usePreferenceDraft(profileBundle);
|
||||||
const [showEnergyPoints, setShowEnergyPoints] = useState(
|
const avatarLimitInMb = PROFILE_AVATAR_MAX_BYTES / (1024 * 1024);
|
||||||
profileBundle.settings.showEnergyPoints,
|
const profileTitle =
|
||||||
);
|
profileBundle.profile.displayName ??
|
||||||
const [morningReminderEnabled, setMorningReminderEnabled] = useState(
|
profileBundle.profile.email ??
|
||||||
profileBundle.settings.morningReminderEnabled,
|
"Ingelogde gebruiker";
|
||||||
);
|
|
||||||
const [morningReminderTime, setMorningReminderTime] = useState(
|
|
||||||
profileBundle.settings.morningReminderTime ?? "08:30",
|
|
||||||
);
|
|
||||||
const [reflectionReminderEnabled, setReflectionReminderEnabled] = useState(
|
|
||||||
profileBundle.settings.reflectionReminderEnabled,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={saveSettingsAction} className="space-y-6">
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="space-y-6"
|
||||||
|
aria-busy={isPending}
|
||||||
|
>
|
||||||
<input type="hidden" name="locale" value={locale} />
|
<input type="hidden" name="locale" value={locale} />
|
||||||
<input type="hidden" name="timezone" value={timezone} />
|
<PreferenceHiddenFields draft={draft} />
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="showEnergyPoints"
|
|
||||||
value={showEnergyPoints ? "true" : "false"}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="morningReminderEnabled"
|
|
||||||
value={morningReminderEnabled ? "true" : "false"}
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="morningReminderTime" value={morningReminderTime} />
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="reflectionReminderEnabled"
|
|
||||||
value={reflectionReminderEnabled ? "true" : "false"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_18px_60px_rgba(71,85,105,0.1)]">
|
<Card elevation="raised" className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Account
|
Account
|
||||||
</p>
|
</p>
|
||||||
<CardTitle className="font-[family-name:var(--font-display)] text-3xl text-slate-900">
|
<CardTitle className="font-[family-name:var(--font-display)] text-3xl text-foreground">
|
||||||
Basisinstellingen voor jouw account
|
Basisinstellingen voor jouw account
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||||
|
|
@ -87,7 +74,7 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-1 pb-6">
|
<CardContent className="pt-1 pb-6">
|
||||||
<Alert className="rounded-[1.5rem] border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700">
|
<Alert variant="info">
|
||||||
<AlertDescription className="leading-7 text-current">
|
<AlertDescription className="leading-7 text-current">
|
||||||
Release 1 draait bewust volledig in het <strong>Nederlands</strong>.
|
Release 1 draait bewust volledig in het <strong>Nederlands</strong>.
|
||||||
De taalinstelling blijft wel al aanwezig in het accountmodel.
|
De taalinstelling blijft wel al aanwezig in het accountmodel.
|
||||||
|
|
@ -96,8 +83,106 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Profiel
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-2xl text-foreground">
|
||||||
|
Laat in een paar regels zien wie je bent
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||||
|
Voeg een naam, korte profielregel, langere beschrijving en een profielfoto toe.
|
||||||
|
Dit helpt straks ook bij demo-accounts en voorbeeldgebruik.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 pb-6 lg:grid-cols-[16rem_1fr]">
|
||||||
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
|
<CardContent className="flex h-full flex-col items-center gap-4 px-5 py-5 text-center">
|
||||||
|
<ProfileAvatar
|
||||||
|
avatarUrl={profileBundle.profile.avatarUrl}
|
||||||
|
displayName={profileBundle.profile.displayName}
|
||||||
|
email={profileBundle.profile.email}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-foreground">{profileTitle}</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{profileBundle.profile.tagline ?? "Nog geen 1-regelige introductie toegevoegd."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full space-y-2 text-left">
|
||||||
|
<Label htmlFor="avatar" className="text-foreground">
|
||||||
|
Profielfoto
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="avatar"
|
||||||
|
name="avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
disabled={isPending}
|
||||||
|
className="h-auto rounded-[1.25rem] bg-background/80 px-4 py-3 file:mr-3 file:rounded-full file:bg-secondary file:px-3 file:py-1.5 file:text-secondary-foreground"
|
||||||
|
/>
|
||||||
|
<p className="text-xs leading-6 text-muted-foreground">
|
||||||
|
JPG, PNG of WebP tot {avatarLimitInMb} MB. Een nieuw bestand vervangt je
|
||||||
|
huidige foto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="display-name" className="text-foreground">
|
||||||
|
Weergavenaam
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="display-name"
|
||||||
|
name="displayName"
|
||||||
|
defaultValue={profileBundle.profile.displayName ?? ""}
|
||||||
|
disabled={isPending}
|
||||||
|
maxLength={80}
|
||||||
|
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base md:text-base"
|
||||||
|
placeholder="Bijvoorbeeld Jan Peter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tagline" className="text-foreground">
|
||||||
|
Wie ben je in 1 regel?
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tagline"
|
||||||
|
name="tagline"
|
||||||
|
defaultValue={profileBundle.profile.tagline ?? ""}
|
||||||
|
disabled={isPending}
|
||||||
|
maxLength={160}
|
||||||
|
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base md:text-base"
|
||||||
|
placeholder="Bijvoorbeeld: rustige planner die energie slim wil verdelen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bio" className="text-foreground">
|
||||||
|
Korte omschrijving
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bio"
|
||||||
|
name="bio"
|
||||||
|
defaultValue={profileBundle.profile.bio ?? ""}
|
||||||
|
disabled={isPending}
|
||||||
|
maxLength={2000}
|
||||||
|
className="min-h-36 rounded-[1.5rem] bg-background/80 px-4 py-3 text-base md:text-base"
|
||||||
|
placeholder="Vertel in een paar zinnen wat belangrijk is in je dagstructuur, energie of ritme."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<section className="grid gap-5 lg:grid-cols-2">
|
<section className="grid gap-5 lg:grid-cols-2">
|
||||||
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
<Card className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Taal en tijd
|
Taal en tijd
|
||||||
|
|
@ -105,8 +190,9 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 pb-6">
|
<CardContent className="space-y-5 pb-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-slate-800">Taal</Label>
|
<Label className="text-foreground">Taal</Label>
|
||||||
<Select
|
<Select
|
||||||
|
disabled={isPending}
|
||||||
value={locale}
|
value={locale}
|
||||||
onValueChange={(value) => setLocale(value ?? profileBundle.profile.locale)}
|
onValueChange={(value) => setLocale(value ?? profileBundle.profile.locale)}
|
||||||
>
|
>
|
||||||
|
|
@ -124,11 +210,14 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-slate-800">Timezone</Label>
|
<Label className="text-foreground">Timezone</Label>
|
||||||
<Select
|
<Select
|
||||||
value={timezone}
|
disabled={isPending}
|
||||||
|
value={draft.timezone}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setTimezone(value ?? profileBundle.profile.timezone)
|
updateDraft({
|
||||||
|
timezone: value ?? profileBundle.profile.timezone,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
|
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
|
||||||
|
|
@ -146,17 +235,17 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
<Card className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Interface
|
Interface
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 pb-6">
|
<CardContent className="space-y-4 pb-6">
|
||||||
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0 shadow-none">
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
<CardContent className="flex items-start justify-between gap-4 py-5">
|
<CardContent className="flex items-start justify-between gap-4 py-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="show-energy-points" className="text-sm font-semibold text-slate-900">
|
<Label htmlFor="show-energy-points" className="text-sm font-semibold text-foreground">
|
||||||
Toon energiebudgetpunten
|
Toon energiebudgetpunten
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm leading-7 text-muted-foreground">
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
|
@ -165,8 +254,11 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="show-energy-points"
|
id="show-energy-points"
|
||||||
checked={showEnergyPoints}
|
disabled={isPending}
|
||||||
onCheckedChange={setShowEnergyPoints}
|
checked={draft.showEnergyPoints}
|
||||||
|
onCheckedChange={(showEnergyPoints) =>
|
||||||
|
updateDraft({ showEnergyPoints })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -175,44 +267,50 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-5 lg:grid-cols-2">
|
<section className="grid gap-5 lg:grid-cols-2">
|
||||||
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
|
<Card className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Reminders
|
Reminders
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 pb-6">
|
<CardContent className="space-y-4 pb-6">
|
||||||
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0 shadow-none">
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
<CardContent className="space-y-4 py-5">
|
<CardContent className="space-y-4 py-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="morning-reminder-enabled" className="text-sm font-semibold text-slate-900">
|
<Label htmlFor="morning-reminder-enabled" className="text-sm font-semibold text-foreground">
|
||||||
Ochtendreminder
|
Ochtendreminder
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm leading-7 text-muted-foreground">
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
Zet een lichte reminder aan voor een rustige start van je check-in.
|
Zet een lichte reminder aan voor een rustige start van je check-in.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="morning-reminder-enabled"
|
id="morning-reminder-enabled"
|
||||||
checked={morningReminderEnabled}
|
disabled={isPending}
|
||||||
onCheckedChange={setMorningReminderEnabled}
|
checked={draft.morningReminderEnabled}
|
||||||
/>
|
onCheckedChange={(morningReminderEnabled) =>
|
||||||
|
updateDraft({ morningReminderEnabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{morningReminderEnabled ? (
|
{draft.morningReminderEnabled ? (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="morning-reminder-time" className="text-slate-800">
|
<Label htmlFor="morning-reminder-time" className="text-foreground">
|
||||||
Tijdstip voor de ochtendreminder
|
Tijdstip voor de ochtendreminder
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="morning-reminder-time"
|
id="morning-reminder-time"
|
||||||
className="h-12 rounded-[1.25rem] bg-white px-4 text-base md:text-base"
|
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base md:text-base"
|
||||||
|
disabled={isPending}
|
||||||
type="time"
|
type="time"
|
||||||
value={morningReminderTime}
|
value={draft.morningReminderTime}
|
||||||
onChange={(event) => setMorningReminderTime(event.target.value)}
|
onChange={(event) =>
|
||||||
|
updateDraft({ morningReminderTime: event.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -220,10 +318,10 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0 shadow-none">
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
<CardContent className="flex items-start justify-between gap-4 py-5">
|
<CardContent className="flex items-start justify-between gap-4 py-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="reflection-reminder-enabled" className="text-sm font-semibold text-slate-900">
|
<Label htmlFor="reflection-reminder-enabled" className="text-sm font-semibold text-foreground">
|
||||||
Reflectieprompts toestaan
|
Reflectieprompts toestaan
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm leading-7 text-muted-foreground">
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
|
@ -232,15 +330,18 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="reflection-reminder-enabled"
|
id="reflection-reminder-enabled"
|
||||||
checked={reflectionReminderEnabled}
|
disabled={isPending}
|
||||||
onCheckedChange={setReflectionReminderEnabled}
|
checked={draft.reflectionReminderEnabled}
|
||||||
|
onCheckedChange={(reflectionReminderEnabled) =>
|
||||||
|
updateDraft({ reflectionReminderEnabled })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="rounded-[1.75rem] border border-primary/15 bg-primary py-0 text-primary-foreground shadow-[0_12px_40px_rgba(22,58,43,0.18)]">
|
<Card tone="primary" elevation="raised" className="py-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
||||||
Bewuste grenzen
|
Bewuste grenzen
|
||||||
|
|
@ -258,11 +359,13 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="text-sm leading-7 text-muted-foreground">
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
Wijzigingen zijn direct van toepassing op jouw account en volgende sessies.
|
{isPending
|
||||||
|
? "Instellingen worden opgeslagen..."
|
||||||
|
: "Wijzigingen zijn direct van toepassing op jouw account en volgende sessies."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
|
<Button type="submit" size="lg" disabled={isPending} className="h-11 rounded-full px-5">
|
||||||
Instellingen opslaan
|
{isPending ? "Instellingen opslaan..." : "Instellingen opslaan"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
205
components/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "inspannings-monitor-theme";
|
||||||
|
|
||||||
|
export type ThemeName = "light" | "dark" | "system";
|
||||||
|
export type ResolvedThemeName = "light" | "dark";
|
||||||
|
|
||||||
|
type ThemeContextValue = {
|
||||||
|
theme: ThemeName;
|
||||||
|
setTheme: (theme: ThemeName) => void;
|
||||||
|
resolvedTheme: ResolvedThemeName;
|
||||||
|
systemTheme: ResolvedThemeName;
|
||||||
|
themes: ThemeName[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultTheme?: ThemeName;
|
||||||
|
enableSystem?: boolean;
|
||||||
|
disableTransitionOnChange?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
const AVAILABLE_THEMES: ThemeName[] = ["light", "dark", "system"];
|
||||||
|
|
||||||
|
function getSystemTheme(): ResolvedThemeName {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredTheme(
|
||||||
|
defaultTheme: ThemeName,
|
||||||
|
enableSystem: boolean,
|
||||||
|
): ThemeName {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return defaultTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedTheme = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (storedTheme === "light" || storedTheme === "dark") {
|
||||||
|
return storedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedTheme === "system" && enableSystem) {
|
||||||
|
return storedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResolvedTheme(resolvedTheme: ResolvedThemeName) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
root.classList.add(resolvedTheme);
|
||||||
|
root.dataset.theme = resolvedTheme;
|
||||||
|
root.style.colorScheme = resolvedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDisabledTransitions(action: () => void) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.appendChild(
|
||||||
|
document.createTextNode(
|
||||||
|
"*{-webkit-transition:none!important;transition:none!important}",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
document.head.appendChild(style);
|
||||||
|
action();
|
||||||
|
|
||||||
|
window.getComputedStyle(document.body);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.head.removeChild(style);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
defaultTheme = "dark",
|
||||||
|
enableSystem = true,
|
||||||
|
disableTransitionOnChange = false,
|
||||||
|
}: ThemeProviderProps) {
|
||||||
|
const initialSystemTheme = React.useMemo(() => getSystemTheme(), []);
|
||||||
|
const [theme, setThemeState] = React.useState<ThemeName>(() =>
|
||||||
|
typeof window === "undefined"
|
||||||
|
? defaultTheme
|
||||||
|
: readStoredTheme(defaultTheme, enableSystem),
|
||||||
|
);
|
||||||
|
const [systemTheme, setSystemTheme] =
|
||||||
|
React.useState<ResolvedThemeName>(initialSystemTheme);
|
||||||
|
const [resolvedTheme, setResolvedTheme] =
|
||||||
|
React.useState<ResolvedThemeName>(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return defaultTheme === "light" ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTheme = readStoredTheme(defaultTheme, enableSystem);
|
||||||
|
return currentTheme === "system" ? getSystemTheme() : currentTheme;
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
function syncTheme(nextTheme: ThemeName) {
|
||||||
|
const nextSystemTheme = getSystemTheme();
|
||||||
|
const nextResolvedTheme =
|
||||||
|
nextTheme === "system" ? nextSystemTheme : nextTheme;
|
||||||
|
|
||||||
|
const apply = () => applyResolvedTheme(nextResolvedTheme);
|
||||||
|
|
||||||
|
if (disableTransitionOnChange) {
|
||||||
|
withDisabledTransitions(apply);
|
||||||
|
} else {
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.dataset.themePreference = nextTheme;
|
||||||
|
setSystemTheme(nextSystemTheme);
|
||||||
|
setResolvedTheme(nextResolvedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncTheme(theme);
|
||||||
|
|
||||||
|
function handleStorage(event: StorageEvent) {
|
||||||
|
if (event.key !== STORAGE_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTheme =
|
||||||
|
event.newValue === "light" ||
|
||||||
|
event.newValue === "dark" ||
|
||||||
|
(event.newValue === "system" && enableSystem)
|
||||||
|
? (event.newValue as ThemeName)
|
||||||
|
: defaultTheme;
|
||||||
|
|
||||||
|
setThemeState(nextTheme);
|
||||||
|
syncTheme(nextTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSystemThemeChange() {
|
||||||
|
const nextSystemTheme = getSystemTheme();
|
||||||
|
setSystemTheme(nextSystemTheme);
|
||||||
|
|
||||||
|
if (theme === "system") {
|
||||||
|
const apply = () => applyResolvedTheme(nextSystemTheme);
|
||||||
|
|
||||||
|
if (disableTransitionOnChange) {
|
||||||
|
withDisabledTransitions(apply);
|
||||||
|
} else {
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
setResolvedTheme(nextSystemTheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("storage", handleStorage);
|
||||||
|
mediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", handleStorage);
|
||||||
|
mediaQuery.removeEventListener("change", handleSystemThemeChange);
|
||||||
|
};
|
||||||
|
}, [defaultTheme, disableTransitionOnChange, enableSystem, theme]);
|
||||||
|
|
||||||
|
const setTheme = React.useCallback(
|
||||||
|
(nextTheme: ThemeName) => {
|
||||||
|
setThemeState(nextTheme);
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, nextTheme);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<ThemeContextValue>(
|
||||||
|
() => ({
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
resolvedTheme,
|
||||||
|
systemTheme,
|
||||||
|
themes: enableSystem ? AVAILABLE_THEMES : ["light", "dark"],
|
||||||
|
}),
|
||||||
|
[enableSystem, resolvedTheme, setTheme, systemTheme, theme],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = React.useContext(ThemeContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTheme must be used within ThemeProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
@ -4,13 +4,16 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const alertVariants = cva(
|
const alertVariants = cva(
|
||||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
"group/alert relative grid w-full gap-0.5 rounded-[var(--radius-xl)] border px-3 py-3 text-left text-sm shadow-[var(--shadow-1)] has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-[1.125rem]",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-card text-card-foreground",
|
default: "border-border/70 bg-card/92 text-card-foreground",
|
||||||
|
info: "border-primary/15 bg-secondary text-foreground",
|
||||||
|
success: "border-success/30 bg-success/14 text-foreground",
|
||||||
|
warning: "border-warning/32 bg-warning/16 text-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
"border-destructive/32 bg-destructive/12 text-foreground *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-destructive",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|
@ -39,7 +42,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="alert-title"
|
data-slot="alert-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
"font-medium leading-6 group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -55,7 +58,7 @@ function AlertDescription({
|
||||||
<div
|
<div
|
||||||
data-slot="alert-description"
|
data-slot="alert-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
"text-sm text-balance leading-7 text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -4,32 +4,37 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-[var(--radius-full,9999px)] border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-150 ease-[cubic-bezier(.2,.7,.2,1)] outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-[1.125rem]",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default:
|
||||||
|
"bg-primary !text-white shadow-[var(--shadow-1)] hover:bg-primary/90 hover:shadow-[var(--shadow-2)] [a]:hover:bg-primary/90 [&_svg]:!text-white",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border-border bg-background/88 hover:bg-muted hover:text-foreground hover:shadow-[var(--shadow-1)] aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"bg-secondary text-secondary-foreground shadow-[var(--shadow-1)] hover:bg-secondary/85 hover:shadow-[var(--shadow-2)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
success:
|
||||||
|
"bg-success !text-white shadow-[var(--shadow-1)] hover:brightness-[0.98] hover:shadow-[var(--shadow-2)] [&_svg]:!text-white",
|
||||||
|
warning:
|
||||||
|
"bg-warning text-foreground shadow-[var(--shadow-1)] hover:brightness-[0.98] hover:shadow-[var(--shadow-2)]",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
"bg-destructive !text-white shadow-[var(--shadow-1)] hover:brightness-[0.98] hover:shadow-[var(--shadow-2)] focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [&_svg]:!text-white",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
xs: "h-6 gap-1 rounded-[var(--radius-sm)] px-2 text-xs in-data-[slot=button-group]:rounded-[var(--radius-sm)] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
sm: "h-7 gap-1 rounded-[var(--radius)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-[var(--radius)] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
icon: "size-8",
|
icon: "size-8",
|
||||||
"icon-xs":
|
"icon-xs":
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"size-6 rounded-[var(--radius-sm)] in-data-[slot=button-group]:rounded-[var(--radius-sm)] [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-sm":
|
"icon-sm":
|
||||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
"size-7 rounded-[var(--radius)] in-data-[slot=button-group]:rounded-[var(--radius)]",
|
||||||
"icon-lg": "size-9",
|
"icon-lg": "size-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,44 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const cardVariants = cva(
|
||||||
|
"group/card flex flex-col gap-4 overflow-hidden rounded-[var(--radius-xl)] py-4 text-sm text-card-foreground ring-1 ring-border/75 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-[var(--radius-xl)] *:[img:last-child]:rounded-b-[var(--radius-xl)]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
tone: {
|
||||||
|
default: "bg-card/92",
|
||||||
|
subtle: "bg-background/78",
|
||||||
|
primary: "bg-primary text-primary-foreground ring-primary/10",
|
||||||
|
},
|
||||||
|
elevation: {
|
||||||
|
flat: "shadow-[var(--shadow-1)]",
|
||||||
|
raised: "shadow-[var(--shadow-2)]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
tone: "default",
|
||||||
|
elevation: "flat",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
function Card({
|
function Card({
|
||||||
className,
|
className,
|
||||||
size = "default",
|
size = "default",
|
||||||
|
tone,
|
||||||
|
elevation,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
}: React.ComponentProps<"div"> &
|
||||||
|
{ size?: "default" | "sm" } &
|
||||||
|
VariantProps<typeof cardVariants>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
cardVariants({ tone, elevation }),
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -25,7 +51,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-[var(--radius-xl)] px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -38,7 +64,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
"font-heading text-base leading-snug font-semibold tracking-[-0.02em] group-data-[size=sm]/card:text-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -84,7 +110,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
"flex items-center rounded-b-[var(--radius-xl)] border-t border-border/65 bg-muted/60 p-4 group-data-[size=sm]/card:p-3",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -100,4 +126,5 @@ export {
|
||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
cardVariants,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/16 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/24 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
155
components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Menu } from "@base-ui/react/menu";
|
||||||
|
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DropdownMenu = Menu.Root;
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Menu.Trigger>) {
|
||||||
|
return (
|
||||||
|
<Menu.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-full border border-border/75 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-all duration-150 ease-[cubic-bezier(.2,.7,.2,1)] hover:border-border hover:bg-card focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="size-4 text-muted-foreground" />
|
||||||
|
</Menu.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DropdownMenuContentProps = React.ComponentProps<typeof Menu.Popup> &
|
||||||
|
Pick<
|
||||||
|
React.ComponentProps<typeof Menu.Positioner>,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 10,
|
||||||
|
align = "end",
|
||||||
|
alignOffset = 0,
|
||||||
|
...props
|
||||||
|
}: DropdownMenuContentProps) {
|
||||||
|
return (
|
||||||
|
<Menu.Portal>
|
||||||
|
<Menu.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
className="z-50"
|
||||||
|
>
|
||||||
|
<Menu.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-60 overflow-hidden rounded-[var(--radius-2xl)] border border-border/80 bg-popover/96 p-1.5 text-popover-foreground shadow-[var(--shadow-3)] backdrop-blur duration-150 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Menu.Popup>
|
||||||
|
</Menu.Positioner>
|
||||||
|
</Menu.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Menu.Item> & { inset?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded-[var(--radius)] px-3 py-2.5 text-sm text-foreground outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
|
inset && "pl-9",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup(
|
||||||
|
props: React.ComponentProps<typeof Menu.RadioGroup>,
|
||||||
|
) {
|
||||||
|
return <Menu.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Menu.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<Menu.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded-[var(--radius)] px-3 py-2.5 text-sm text-foreground outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[checked]:text-primary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="flex size-4 items-center justify-center">
|
||||||
|
<Menu.RadioItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Menu.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
<span>{children}</span>
|
||||||
|
</Menu.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Menu.Separator>) {
|
||||||
|
return (
|
||||||
|
<Menu.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("my-1 h-px bg-border/80", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
};
|
||||||
|
|
@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
"h-8 w-full min-w-0 rounded-[var(--radius)] border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-border/80 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/16 md:text-sm dark:bg-input/30 dark:hover:bg-input/45 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/24",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ function SelectTrigger({
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex w-fit items-center justify-between gap-1.5 rounded-[var(--radius)] border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none hover:border-border/80 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/16 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[var(--radius-sm)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/24 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -83,7 +83,7 @@ function SelectContent({
|
||||||
<SelectPrimitive.Popup
|
<SelectPrimitive.Popup
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
data-align-trigger={alignItemWithTrigger}
|
data-align-trigger={alignItemWithTrigger}
|
||||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-[var(--radius-lg)] bg-popover text-popover-foreground shadow-[var(--shadow-2)] ring-1 ring-border/80 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
|
|
|
||||||
10
components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
OctagonXIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTheme } from "@/components/theme-provider";
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={resolvedTheme === "light" ? "light" : "dark"}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
info: <InfoIcon className="size-4" />,
|
||||||
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
|
error: <OctagonXIcon className="size-4" />,
|
||||||
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--success-bg": "var(--success)",
|
||||||
|
"--success-text": "var(--primary-foreground)",
|
||||||
|
"--warning-bg": "var(--warning)",
|
||||||
|
"--warning-text": "var(--foreground)",
|
||||||
|
"--error-bg": "var(--destructive)",
|
||||||
|
"--error-text": "var(--primary-foreground)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
|
|
@ -16,7 +16,7 @@ function Switch({
|
||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/16 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/24 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
16
components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-28 w-full rounded-[var(--radius)] border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground hover:border-border/80 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/16 md:text-sm dark:bg-input/30 dark:hover:bg-input/45 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/24",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
146
components/wizard/test-wizard-flow.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { WizardShell } from "@/components/wizard/wizard-shell";
|
||||||
|
import { useWizardFlow } from "@/lib/wizard/use-wizard-flow";
|
||||||
|
import type { WizardStepDefinition } from "@/lib/wizard/types";
|
||||||
|
|
||||||
|
type TestWizardDraft = Record<string, never>;
|
||||||
|
|
||||||
|
const steps: WizardStepDefinition<TestWizardDraft>[] = [
|
||||||
|
{
|
||||||
|
id: "step-1",
|
||||||
|
eyebrow: "Stap 1",
|
||||||
|
title: "Stap 1",
|
||||||
|
description: "Eerste simpele teststap om de wizard-shell en navigatie te valideren.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "step-2",
|
||||||
|
eyebrow: "Stap 2",
|
||||||
|
title: "Stap 2",
|
||||||
|
description: "Tweede stap voor controle van voortgang, vorige/volgende en layoutconsistentie.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "step-3",
|
||||||
|
eyebrow: "Stap 3",
|
||||||
|
title: "Stap 3",
|
||||||
|
description: "Derde stap om te bevestigen dat multi-step flows generiek inzetbaar blijven.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "step-4",
|
||||||
|
eyebrow: "Stap 4",
|
||||||
|
title: "Stap 4",
|
||||||
|
description: "Vierde stap als tussenpunt vlak voor afronding van de testflow.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "step-5",
|
||||||
|
eyebrow: "Stap 5",
|
||||||
|
title: "Stap 5",
|
||||||
|
description: "Laatste stap voor afronding en redirect terug naar het dashboard.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const testStepDescriptions: Record<string, string> = {
|
||||||
|
"step-1": "Deze stap bewijst dat de flow op de eerste positie correct opstart.",
|
||||||
|
"step-2": "Deze stap bewijst dat vooruit navigeren geen state of layout breekt.",
|
||||||
|
"step-3": "Deze stap is het midden van de flow en is handig voor regressietests.",
|
||||||
|
"step-4": "Deze stap bevestigt dat de shell netjes blijft werken richting het einde.",
|
||||||
|
"step-5": "Deze stap bevestigt dat afronden als aparte actie werkt op de laatste stap.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TestWizardFlow() {
|
||||||
|
const router = useRouter();
|
||||||
|
const wizard = useWizardFlow({
|
||||||
|
steps,
|
||||||
|
draft: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
function finishWizard() {
|
||||||
|
router.push("/dashboard?status=test-wizard-completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const aside = (
|
||||||
|
<Alert className="rounded-[var(--radius-2xl)] border-white/10 bg-white/8 text-primary-foreground [&_svg]:text-primary-foreground/80">
|
||||||
|
<AlertDescription className="leading-7 text-current">
|
||||||
|
<span className="block font-semibold">Interne testwizard</span>
|
||||||
|
<span className="mt-2 block">
|
||||||
|
Alleen bedoeld om de generieke wizard-core te controleren voor toekomstige flows.
|
||||||
|
</span>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
|
||||||
|
const topAction = (
|
||||||
|
<>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Test wizard
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full"
|
||||||
|
onClick={() => router.push("/dashboard")}
|
||||||
|
>
|
||||||
|
Terug naar dashboard
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const backAction = (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={wizard.goToPreviousStep}
|
||||||
|
disabled={wizard.isFirstStep}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
Vorige
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextAction = wizard.isLastStep ? (
|
||||||
|
<Button type="button" className="rounded-full" onClick={finishWizard}>
|
||||||
|
Rond testwizard af
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" className="rounded-full" onClick={wizard.goToNextStep}>
|
||||||
|
Ga verder
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WizardShell
|
||||||
|
eyebrow={wizard.currentStep.eyebrow}
|
||||||
|
title={wizard.currentStep.title}
|
||||||
|
description={wizard.currentStep.description}
|
||||||
|
progressCurrent={wizard.currentStepIndex + 1}
|
||||||
|
progressTotal={wizard.steps.length}
|
||||||
|
topAction={topAction}
|
||||||
|
aside={aside}
|
||||||
|
backAction={backAction}
|
||||||
|
nextAction={nextAction}
|
||||||
|
>
|
||||||
|
<Card tone="subtle" className="py-0 shadow-none">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
||||||
|
{wizard.currentStep.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{testStepDescriptions[wizard.currentStep.id]}
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</WizardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
components/wizard/wizard-progress.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type WizardProgressProps = {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WizardProgress({ current, total }: WizardProgressProps) {
|
||||||
|
return (
|
||||||
|
<ol className="mt-8 flex gap-3" aria-label="Voortgang">
|
||||||
|
{Array.from({ length: total }, (_, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
aria-current={index + 1 === current ? "step" : undefined}
|
||||||
|
aria-label={`Stap ${index + 1} van ${total}`}
|
||||||
|
className={cn(
|
||||||
|
"h-2 flex-1 rounded-full transition-colors",
|
||||||
|
index < current ? "bg-primary-foreground/85" : "bg-white/15",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Stap {index + 1} van {total}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
components/wizard/wizard-shell.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { WizardProgress } from "@/components/wizard/wizard-progress";
|
||||||
|
|
||||||
|
type WizardShellProps = {
|
||||||
|
eyebrow?: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
progressCurrent: number;
|
||||||
|
progressTotal: number;
|
||||||
|
topAction?: ReactNode;
|
||||||
|
aside?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
backAction?: ReactNode;
|
||||||
|
nextAction?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WizardShell({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
progressCurrent,
|
||||||
|
progressTotal,
|
||||||
|
topAction,
|
||||||
|
aside,
|
||||||
|
children,
|
||||||
|
backAction,
|
||||||
|
nextAction,
|
||||||
|
}: WizardShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||||
|
<section className="app-panel-primary rounded-[var(--radius-4xl)] p-7 sm:p-9">
|
||||||
|
{eyebrow ? (
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/70">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-tight sm:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-5 max-w-xl text-base leading-8 text-primary-foreground/85">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{aside ? <div className="mt-10">{aside}</div> : null}
|
||||||
|
<WizardProgress current={progressCurrent} total={progressTotal} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[var(--radius-4xl)] border border-border/70 bg-card/86 p-6 shadow-[var(--shadow-2)] backdrop-blur sm:p-8">
|
||||||
|
{topAction ? (
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-3">{topAction}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-6">{children}</div>
|
||||||
|
{backAction || nextAction ? (
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>{backAction}</div>
|
||||||
|
<div>{nextAction}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,16 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem
|
||||||
- Database: `Supabase PostgreSQL`
|
- Database: `Supabase PostgreSQL`
|
||||||
- Authenticatie: `Supabase Auth`
|
- Authenticatie: `Supabase Auth`
|
||||||
- UI foundation in de app: `Tailwind CSS + shadcn/ui`
|
- UI foundation in de app: `Tailwind CSS + shadcn/ui`
|
||||||
|
- Visueel thema in de app: `Dusk` met dark-mode prioriteit en semantische statuskleuren
|
||||||
|
|
||||||
|
## Actuele app-status
|
||||||
|
|
||||||
|
- `ST-201` t/m `ST-203` zijn in de code gerealiseerd
|
||||||
|
- Ochtendcheck-in slaat nu energiescore en slaapkwaliteit per dag op
|
||||||
|
- Dagbudget v1 is bewust eenvoudig: `daily_budget = energy_score`
|
||||||
|
- Energieniveau en budget worden al direct getoond in check-in en dashboard
|
||||||
|
- `ST-301`, `ST-302`, `ST-304` en `ST-305` leggen nu ook het activiteitenmodel en de eerste dagplanningflow vast
|
||||||
|
- Eerste unit tests voor budget- en meterlogica draaien via `Vitest`
|
||||||
|
|
||||||
## Generator
|
## Generator
|
||||||
|
|
||||||
|
|
@ -44,6 +54,14 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem
|
||||||
|
|
||||||
- [inspannings-monitor-cicd-en-deploy.md](./inspannings-monitor-cicd-en-deploy.md)
|
- [inspannings-monitor-cicd-en-deploy.md](./inspannings-monitor-cicd-en-deploy.md)
|
||||||
Beschrijft de gekozen CI/CD-opzet met GitHub Actions voor verificatie en Vercel voor automatische preview- en production-deploys.
|
Beschrijft de gekozen CI/CD-opzet met GitHub Actions voor verificatie en Vercel voor automatische preview- en production-deploys.
|
||||||
|
- [inspannings-monitor-dagelijkse-deploy-checklist.md](./inspannings-monitor-dagelijkse-deploy-checklist.md)
|
||||||
|
Korte operationele checklist voor de normale flow van feature branch naar productie.
|
||||||
|
- [inspannings-monitor-ops-security-notitie.md](./inspannings-monitor-ops-security-notitie.md)
|
||||||
|
Legt de actuele operationele en security-keuzes vast rond repositorybescherming, Vercel-deploys en secretbeheer.
|
||||||
|
- [gpt-instructies.md](./gpt-instructies.md)
|
||||||
|
Bundelt de inhoudelijke instructies en expliciete keuzes die in deze context zijn gegeven als compacte bron voor vervolgwerk.
|
||||||
|
- [inspannings-monitor-09-dusk-theme-specificatie-v01.md](./inspannings-monitor-09-dusk-theme-specificatie-v01.md)
|
||||||
|
Legt het leidende Dusk-thema vast voor kleur, typografie, iconografie, motion en toegankelijkheidsregels in de app.
|
||||||
|
|
||||||
## Backlog en Linear
|
## Backlog en Linear
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,37 +64,43 @@ Doel: iedere gebruiker kan veilig een eigen account en basisinstellingen beheren
|
||||||
|
|
||||||
Doel: de gebruiker kan met minimale inspanning de dag starten en een budget krijgen.
|
Doel: de gebruiker kan met minimale inspanning de dag starten en een budget krijgen.
|
||||||
|
|
||||||
|
Status: `ST-201`, `ST-202`, `ST-203`, `ST-204` en `ST-205` zijn inmiddels gerealiseerd in de app. De volgende logische stap ligt nu in `EPIC-04 Dagplanning`.
|
||||||
|
|
||||||
| Story ID | Titel | Type | Definition of done |
|
| Story ID | Titel | Type | Definition of done |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| ST-201 | EnergySlider en SleepQualityInput bouwen | UI | Check-in kan mobiel comfortabel worden ingevuld |
|
| ST-201 | EnergySlider en SleepQualityInput bouwen | UI | Afgerond: check-in kan mobiel comfortabel worden ingevuld |
|
||||||
| ST-202 | Server action voor createMorningCheckIn | Build | Check-in wordt opgeslagen met juiste validatie |
|
| ST-202 | Server action voor createMorningCheckIn | Build | Afgerond: check-in wordt opgeslagen met juiste validatie |
|
||||||
| ST-203 | Budgetlogica implementeren | Logic | Score mapping en budgetberekening zijn consistent en testbaar |
|
| ST-203 | Budgetlogica implementeren | Logic | Afgerond: score mapping en budgetberekening zijn consistent en testbaar |
|
||||||
| ST-204 | Check-instatus op dashboard tonen | UI | Gebruiker ziet direct score, niveau en budget |
|
| ST-204 | Check-instatus op dashboard tonen | UI | Afgerond: gebruiker ziet direct score, niveau en budget |
|
||||||
| ST-205 | Unit tests voor score- en budgetmapping | QA | Belangrijkste grenswaarden zijn afgedekt |
|
| ST-205 | Unit tests voor score- en budgetmapping | QA | Afgerond: belangrijkste grenswaarden zijn afgedekt |
|
||||||
|
|
||||||
## EPIC-04 Dagplanning
|
## EPIC-04 Dagplanning
|
||||||
|
|
||||||
Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig energiemodel.
|
Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig energiemodel.
|
||||||
|
|
||||||
|
Status: `ST-301`, `ST-302`, `ST-303`, `ST-304` en `ST-305` zijn inmiddels gerealiseerd in de app. De dagplanningloop voor release 1 is daarmee functioneel rond.
|
||||||
|
|
||||||
| Story ID | Titel | Type | Definition of done |
|
| Story ID | Titel | Type | Definition of done |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| ST-301 | Datamodel voor activiteiten implementeren | Build | Migraties en seed-data voor categorieën en skip-redenen zijn aanwezig |
|
| ST-301 | Datamodel voor activiteiten implementeren | Build | Afgerond: migraties en seed-data voor categorieën en skip-redenen zijn aanwezig |
|
||||||
| ST-302 | Planningformulier bouwen | UI | Activiteit kan met naam, categorie, duur, impact en prioriteit worden aangemaakt |
|
| ST-302 | Planningformulier bouwen | UI | Afgerond: activiteit kan met naam, categorie, duur, impact en prioriteit worden aangemaakt |
|
||||||
| ST-303 | Autocomplete op eerdere activiteiten toevoegen | UX | Veelgebruikte activiteiten zijn snel opnieuw te kiezen |
|
| ST-303 | Autocomplete op eerdere activiteiten toevoegen | UX | Afgerond: planning en ad-hocformulier bieden nu snelle hergebruiksuggesties uit eigen historie |
|
||||||
| ST-304 | EnergyMeter en lopend totaal implementeren | Logic/UI | Totaal update direct na elke wijziging |
|
| ST-304 | EnergyMeter en lopend totaal implementeren | Logic/UI | Afgerond: totaal update direct na elke wijziging |
|
||||||
| ST-305 | Overschrijdingswaarschuwing toevoegen | UX | Gebruiker krijgt feedback maar behoudt regie |
|
| ST-305 | Overschrijdingswaarschuwing toevoegen | UX | Afgerond: gebruiker krijgt feedback maar behoudt regie |
|
||||||
|
|
||||||
## EPIC-05 Evaluatie en dagoverzicht
|
## EPIC-05 Evaluatie en dagoverzicht
|
||||||
|
|
||||||
Doel: de kernloop afronden door geplande activiteiten te evalueren en terug te zien.
|
Doel: de kernloop afronden door geplande activiteiten te evalueren en terug te zien.
|
||||||
|
|
||||||
|
Status: `ST-401`, `ST-402`, `ST-403`, `ST-404` en `ST-405` zijn inmiddels gerealiseerd in de app. De evaluatielus voor release 1 is daarmee functioneel rond; de volgende logische stap ligt nu weer in `ST-303` en `EPIC-06`.
|
||||||
|
|
||||||
| Story ID | Titel | Type | Definition of done |
|
| Story ID | Titel | Type | Definition of done |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| ST-401 | Statusflows voor uitgevoerd, geskipt en aangepast bouwen | Build | Alle drie de statussen worden correct opgeslagen |
|
| ST-401 | Statusflows voor uitgevoerd, geskipt en aangepast bouwen | Build | Afgerond: activiteiten van vandaag kunnen direct tussen de vier statussen wisselen |
|
||||||
| ST-402 | Evaluatievelden toevoegen | UI | Contextuele velden verschijnen passend per status |
|
| ST-402 | Evaluatievelden toevoegen | UI | Afgerond: skip-reden en toelichting verschijnen passend per status en worden opgeslagen |
|
||||||
| ST-403 | Ongeplande activiteiten ondersteunen | Build | Ongeplande activiteit telt mee in werkelijke totalen |
|
| ST-403 | Ongeplande activiteiten ondersteunen | Build | Afgerond: ongeplande activiteit kan als ad-hoc item worden opgeslagen en telt mee in het dagtotaal |
|
||||||
| ST-404 | Dagoverzicht bouwen | UI | Gepland versus uitgevoerd en statusverdeling zijn zichtbaar |
|
| ST-404 | Dagoverzicht bouwen | UI | Afgerond: planning toont nu gepland versus werkelijk en een directe statusverdeling van de dag |
|
||||||
| ST-405 | Dagaggregaties server-side implementeren | Logic | Dagtotalen blijven consistent met individuele records |
|
| ST-405 | Dagaggregaties server-side implementeren | Logic | Afgerond: dagoverzichttotalen worden nu server-side voorbereid via de planningservice en blijven consistent met individuele records |
|
||||||
|
|
||||||
## EPIC-06 Weekoverzicht en inzichten
|
## EPIC-06 Weekoverzicht en inzichten
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1193,6 +1193,398 @@ def build_implementatieplan_backlog() -> None:
|
||||||
doc.save(BASE_DIR / "inspannings-monitor-06-implementatieplan-en-backlog-v01.docx")
|
doc.save(BASE_DIR / "inspannings-monitor-06-implementatieplan-en-backlog-v01.docx")
|
||||||
|
|
||||||
|
|
||||||
|
def build_testplan() -> None:
|
||||||
|
doc = init_doc(
|
||||||
|
f"{PRODUCT_NAME} Testplan v0.1",
|
||||||
|
f"Teststrategie, tooling en acceptatiecriteria voor de wellness-first MVP\n{DATE_TEXT}",
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "1. Documentdoel", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
f"Dit document beschrijft hoe {PRODUCT_NAME} getest wordt: welke lagen worden afgedekt, welke frameworks worden ingezet, "
|
||||||
|
"hoe tests georganiseerd zijn en wat de Definition of Done is per testlaag. "
|
||||||
|
"Het is bedoeld als praktische leidraad voor engineers die nieuwe features bouwen en als reviewdocument voor kwaliteitsborging vóór launch. "
|
||||||
|
"De strategie gaat uit van de huidige technische keuzes: Next.js App Router, Supabase PostgreSQL met RLS, TypeScript en Vercel.",
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "2. Testpiramide en scope", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"De teststrategie volgt een klassieke piramide met vier lagen. Elke laag heeft een eigen doel, tooling en uitvoerfrequentie. "
|
||||||
|
"De nadruk ligt op de onderste twee lagen, omdat de domeinlogica van dit product (budgetberekening, insightregels, RLS-afdwinging) "
|
||||||
|
"het meest waardevol is om snel en automatisch te verifiëren.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Laag", "Wat wordt getest", "Framework", "Wanneer"],
|
||||||
|
[
|
||||||
|
["Unit", "Pure functies, berekeningslogica, Zod-schema's, hulpfuncties", "Vitest", "Bij elke commit"],
|
||||||
|
["Integratie", "Servicelaag, server actions, Supabase-queries, RLS via pgTAP", "Vitest + pgTAP", "Bij elke commit"],
|
||||||
|
["End-to-end", "Volledige gebruikersflows in echte browser", "Playwright", "Bij PR naar main"],
|
||||||
|
["Handmatig / QA", "Toegankelijkheid, lage interactielast, foutmeldingen, regressie", "Checklist", "Vóór elke release"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "3. Tooling en frameworks", "Heading 1")
|
||||||
|
p(doc, "3.1 Vitest — unit en integratietests", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Vitest is de aanbevolen testruner voor Next.js-projecten in 2026. Het start sneller dan Jest, heeft native ESM-ondersteuning "
|
||||||
|
"en werkt goed samen met TypeScript zonder extra transpilatiestap. "
|
||||||
|
"Vitest ondersteunt momenteel geen asynchrone Server Components (React 19); daarvoor wordt Playwright ingezet. "
|
||||||
|
"Synchrone server-side logica in lib/ en app/**/actions.ts kan wel met Vitest worden getest.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Pakket", "Doel"],
|
||||||
|
[
|
||||||
|
["vitest", "Testruner en assertion library"],
|
||||||
|
["@vitejs/plugin-react", "React JSX-ondersteuning in Vitest"],
|
||||||
|
["jsdom", "Browser-omgeving voor component-snapshot tests"],
|
||||||
|
["@testing-library/react", "Renderen en interacteren met React-componenten"],
|
||||||
|
["@testing-library/dom", "DOM-queries die dicht bij gebruikersgedrag liggen"],
|
||||||
|
["vite-tsconfig-paths", "Ondersteuning voor @ padalias uit tsconfig.json"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
p(doc, "Installatie:", "Normal")
|
||||||
|
p(doc, "npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths", "Normal")
|
||||||
|
p(doc, "Testcommando's:", "Normal")
|
||||||
|
p(doc, "npx vitest — alle unit- en integratietests uitvoeren", "Normal")
|
||||||
|
p(doc, "npx vitest run --reporter=verbose — eenmalig uitvoeren met gedetailleerde output", "Normal")
|
||||||
|
p(doc, "npx vitest run src/lib/checkin/budget.test.ts — één testbestand uitvoeren", "Normal")
|
||||||
|
|
||||||
|
p(doc, "3.2 Playwright — end-to-end tests", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Playwright test de volledige applicatie in een echte browser. Het is de enige manier om asynchrone Server Components, "
|
||||||
|
"Next.js-redirects en Supabase-authenticatie samen als één systeem te verifiëren. "
|
||||||
|
"Voor Supabase-authenticatie wordt programmatische login via de REST API aanbevolen in plaats van UI-gebaseerde login per test, "
|
||||||
|
"zodat tests sneller zijn en minder foutgevoelig.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Pakket", "Doel"],
|
||||||
|
[
|
||||||
|
["@playwright/test", "Testruner, assertions en browser-automatisering"],
|
||||||
|
["playwright", "Browseromgevingen (Chromium, Firefox, WebKit)"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
p(doc, "Installatie:", "Normal")
|
||||||
|
p(doc, "npm install -D @playwright/test && npx playwright install", "Normal")
|
||||||
|
p(doc, "Testcommando's:", "Normal")
|
||||||
|
p(doc, "npx playwright test — alle E2E-tests uitvoeren", "Normal")
|
||||||
|
p(doc, "npx playwright test auth/ — één map uitvoeren", "Normal")
|
||||||
|
p(doc, "npx playwright test --ui — interactieve testrunner met tijdlijn", "Normal")
|
||||||
|
p(doc, "npx playwright test --workers=1 — bij Supabase connection-limiet in CI", "Normal")
|
||||||
|
|
||||||
|
p(doc, "3.3 Zod — runtime-validatie en testschema's", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Zod wordt ingezet als de enige bron van waarheid voor invoervalidatie. "
|
||||||
|
"Zod-schema's worden gedefinieerd in lib/*/schemas.ts en hergebruikt in server actions (server-side validatie) "
|
||||||
|
"en client-componenten (real-time feedback). "
|
||||||
|
"Dit elimineert dubbele validatielogica en maakt het makkelijker om testdata te genereren.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Pakket", "Doel"],
|
||||||
|
[
|
||||||
|
["zod", "Runtime-validatie met TypeScript-type-inferentie"],
|
||||||
|
["zod-fixture", "Automatisch genereren van testfixtures vanuit een Zod-schema"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
p(doc, "Installatie:", "Normal")
|
||||||
|
p(doc, "npm install zod && npm install -D zod-fixture", "Normal")
|
||||||
|
p(doc, "Voorbeeldschema (lib/checkin/schemas.ts):", "Normal")
|
||||||
|
p(doc, 'import { z } from "zod"', "Normal")
|
||||||
|
p(doc, "export const MorningCheckInSchema = z.object({", "Normal")
|
||||||
|
p(doc, " energyScore: z.number().int().min(1).max(10),", "Normal")
|
||||||
|
p(doc, ' sleepQuality: z.enum(["good", "fair", "poor"]),', "Normal")
|
||||||
|
p(doc, " note: z.string().max(500).optional(),", "Normal")
|
||||||
|
p(doc, "})", "Normal")
|
||||||
|
p(doc, "export type MorningCheckIn = z.infer<typeof MorningCheckInSchema>", "Normal")
|
||||||
|
|
||||||
|
p(doc, "3.4 pgTAP — database- en RLS-tests", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"pgTAP is een unit-testframework voor PostgreSQL dat direct in de database draait. "
|
||||||
|
"Het is de meest betrouwbare manier om RLS-beleid te testen, omdat het dezelfde uitvoeringslaag gebruikt als de echte applicatie. "
|
||||||
|
"Tests worden uitgevoerd met de Supabase lokale ontwikkelomgeving of een dedicated testdatabase.",
|
||||||
|
)
|
||||||
|
p(doc, "Supabase biedt ingebouwde ondersteuning voor pgTAP via supabase test db.", "Normal")
|
||||||
|
p(doc, "Testcommando:", "Normal")
|
||||||
|
p(doc, "supabase test db", "Normal")
|
||||||
|
p(doc, "Testbestanden staan in supabase/tests/*.sql en volgen de naamgeving test_<tabelnaam>_rls.sql.", "Normal")
|
||||||
|
|
||||||
|
p(doc, "4. Layer 1: Unit tests", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Unit tests dekken pure functies en logica die geen externe afhankelijkheden hebben. "
|
||||||
|
"Dit zijn de snelste en meest stabiele tests. Ze worden co-located met de bronbestanden in __tests__-mappen of als .test.ts-bestanden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "4.1 Budgetberekening (ST-203 — hoogste prioriteit)", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"De mapping van energiescore naar energieniveau en dagbudget is de kern van het product. "
|
||||||
|
"Dit is de eerste plek waar tests verplicht zijn. De functie moet puur zijn: geen neveneffecten, geen database-oproepen.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Testgeval", "Input", "Verwacht resultaat"],
|
||||||
|
[
|
||||||
|
["Minimale score", "energyScore = 1", "energyLevel = 'very_low', dailyBudget = minimumwaarde"],
|
||||||
|
["Maximale score", "energyScore = 10", "energyLevel = 'high', dailyBudget = maximumwaarde"],
|
||||||
|
["Grenswaarden", "elke overgangswaarde in de schaal", "Correct niveau en bijbehorend budget"],
|
||||||
|
["Consistentie", "zelfde score twee keer", "Altijd gelijk resultaat (deterministisch)"],
|
||||||
|
["Ongeldige invoer", "energyScore = 0 of 11", "Zod gooit een ZodError"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "4.2 Zod-schema's", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Elk domeinobject krijgt een Zod-schema. De schema's worden getest door geldige en ongeldige invoer te parseren "
|
||||||
|
"en het resultaat te verifiëren. Gebruik zod-fixture om realistische testfixtures te genereren.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Schema", "Locatie", "Te testen gevallen"],
|
||||||
|
[
|
||||||
|
["MorningCheckInSchema", "lib/checkin/schemas.ts", "Geldige check-in, score buiten bereik, ontbrekend verplicht veld, te lange notitie"],
|
||||||
|
["OnboardingSubmissionSchema", "lib/onboarding/schemas.ts", "Geldige onboarding, ongeldige tijdzone, ongeldige schermnaam"],
|
||||||
|
["SettingsSubmissionSchema", "lib/profile/schemas.ts", "Geldige settings, ongeldige herinneringstijd, onbekende locale"],
|
||||||
|
["PlannedActivitySchema", "lib/planning/schemas.ts", "Geldige activiteit, negatieve energiepunten, te lange naam"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "4.3 Hulpfuncties en navigatie-utilities", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Functie", "Bestand", "Te testen gevallen"],
|
||||||
|
[
|
||||||
|
["sanitizeNextPath()", "lib/auth/navigation.ts", "Geldig pad, pad zonder leading slash, dubbele slash (open redirect), leeg pad"],
|
||||||
|
["buildPathWithQuery()", "lib/auth/navigation.ts", "Pad zonder params, één param, meerdere params, speciale tekens in waarde"],
|
||||||
|
["getAuthNotice()", "lib/auth/messages.ts", "Bekende foutcode, onbekende code, ontbrekende code, bekende statuscode"],
|
||||||
|
["cn()", "lib/utils.ts", "Lege invoer, conflicterende Tailwind-klassen, conditionals"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "5. Layer 2: Integratietests", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Integratietests verifiëren dat de servicelaag correct samenwerkt met Supabase. "
|
||||||
|
"Server actions worden niet direct getest — de businesslogica zit in de servicelaag (lib/*/service.ts) "
|
||||||
|
"en wordt daar getest. Server actions worden afgedekt door E2E-tests.",
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "5.1 Servicelaag (lib/profile/service.ts en toekomstige services)", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Gebruik een geïsoleerde testdatabase (Supabase lokaal of een aparte testproject-URL). "
|
||||||
|
"Elke test maakt eigen data aan en ruimt die na afloop op. Gebruik vi.mock() niet voor de database — "
|
||||||
|
"echte Supabase-queries geven meer vertrouwen en voorkomen dat mock-gedrag verschilt van productiegedrag.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Test", "Doel"],
|
||||||
|
[
|
||||||
|
["getProfileBundleForCurrentUser()", "Retourneert gecombineerd profiel en settings voor bestaande gebruiker"],
|
||||||
|
["ensureProfileBundleForCurrentUser()", "Maakt records aan als ze niet bestaan (bootstrap)"],
|
||||||
|
["completeOnboardingForCurrentUser()", "Slaat onboarding op en zet onboarding_seen op true"],
|
||||||
|
["saveSettingsForCurrentUser()", "Wijzigingen worden persistent opgeslagen"],
|
||||||
|
["getProfileBundleForCurrentUser() — niet ingelogd", "Gooit een fout of retourneert null"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "5.2 Server actions — mocking aanpak", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Server actions zijn dunne wrappers rond de servicelaag. Ze worden getest via vi.mock() voor next/navigation "
|
||||||
|
"om redirect-gedrag te verifiëren, en via E2E-tests voor de volledige flow. "
|
||||||
|
"De businesslogica (validatie, berekening) wordt in unit- en integratietests afgedekt.",
|
||||||
|
)
|
||||||
|
p(doc, "Aanbevolen patroon voor server action tests:", "Normal")
|
||||||
|
p(doc, "vi.mock('next/navigation', () => ({ redirect: vi.fn() }))", "Normal")
|
||||||
|
p(doc, "vi.mock('@/lib/profile/service') // mock de servicelaag", "Normal")
|
||||||
|
p(doc, "// Test de actie en verifieer dat redirect en service correct worden aangeroepen", "Normal")
|
||||||
|
|
||||||
|
p(doc, "6. Layer 3: RLS en security tests (pgTAP)", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"RLS-tests worden uitgevoerd direct in PostgreSQL via pgTAP. "
|
||||||
|
"Elke tabel krijgt een eigen testbestand. De tests verifiëren dat gebruikers uitsluitend hun eigen records kunnen lezen, "
|
||||||
|
"schrijven en verwijderen. Tests worden uitgevoerd als een niet-geprivilegieerde databaserol, "
|
||||||
|
"niet als de SQL Editor-rol (die RLS omzeilt).",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Testgeval", "Te verifiëren"],
|
||||||
|
[
|
||||||
|
["SELECT op eigen rij", "Gebruiker A kan zijn eigen profiel opvragen"],
|
||||||
|
["SELECT op andermans rij", "Gebruiker A kan het profiel van gebruiker B niet zien (0 rijen)"],
|
||||||
|
["INSERT voor zichzelf", "Gebruiker A mag een check-in aanmaken voor eigen profiel"],
|
||||||
|
["INSERT voor een ander", "Gebruiker A kan geen check-in aanmaken voor profiel van B (RLS-fout)"],
|
||||||
|
["UPDATE op eigen rij", "Gebruiker A mag eigen settings aanpassen"],
|
||||||
|
["UPDATE op andermans rij", "Gebruiker A kan settings van B niet aanpassen (0 updated rows)"],
|
||||||
|
["DELETE op eigen rij", "Verwijderen van eigen record lukt"],
|
||||||
|
["DELETE op andermans rij", "Verwijderen van andermans record lukt niet"],
|
||||||
|
["Unauthenticated access", "Queries zonder geldig JWT retourneren 0 rijen of een fout"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
p(doc, "Testbestandsstructuur:", "Normal")
|
||||||
|
p(doc, "supabase/tests/test_profiles_rls.sql", "Normal")
|
||||||
|
p(doc, "supabase/tests/test_user_settings_rls.sql", "Normal")
|
||||||
|
p(doc, "supabase/tests/test_morning_check_ins_rls.sql", "Normal")
|
||||||
|
p(doc, "supabase/tests/test_activities_rls.sql", "Normal")
|
||||||
|
|
||||||
|
p(doc, "7. Layer 4: End-to-end tests (Playwright)", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"E2E-tests verifiëren de volledige gebruikersflows in een echte browser. "
|
||||||
|
"Elke testrun gebruikt een authentiek Supabase-testaccount. "
|
||||||
|
"Authenticatie gebeurt programmatisch via de Supabase REST API om tijd te besparen en flakiness te beperken: "
|
||||||
|
"het auth-token wordt eenmalig opgehaald in een setup-stap en hergebruikt als cookie-state voor alle tests.",
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "7.1 Authenticatiepatroon", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Maak een global setup-bestand (playwright/global-setup.ts) dat één keer inlogt via de Supabase Auth REST API "
|
||||||
|
"en de sessiestatus opslaat in playwright/.auth/user.json. "
|
||||||
|
"Testbestanden importeren deze opgeslagen staat en starten al ingelogd.",
|
||||||
|
)
|
||||||
|
p(doc, "Voordeel: authenticatie hoeft maar één keer per testsuite te draaien, niet per test.", "Normal")
|
||||||
|
p(doc, "In CI: gebruik --workers=1 als de Supabase connection pool dat vereist.", "Normal")
|
||||||
|
p(doc, "Gebruik data-testid-attributen op interactieve elementen voor stabiele selectors.", "Normal")
|
||||||
|
|
||||||
|
p(doc, "7.2 Te testen gebruikersflows", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Flow", "Stappen", "Kritieke assertions"],
|
||||||
|
[
|
||||||
|
["Registratie en e-mailbevestiging", "Aanmelden, e-mail bevestigen, onboarding afronden", "Dashboard is bereikbaar na bevestiging"],
|
||||||
|
["Inloggen", "Inlogformulier invullen, submit", "Dashboard zichtbaar, naam of profiel aanwezig"],
|
||||||
|
["Onboarding", "Drie stappen doorlopen, tijdzone en herinneringen instellen", "Dashboard toont welkomstbericht, onboarding niet opnieuw zichtbaar"],
|
||||||
|
["Instellingen wijzigen", "Naar instellingen navigeren, tijdzone aanpassen, opslaan", "Succesbericht zichtbaar, nieuwe instelling persistent"],
|
||||||
|
["Ochtendcheck-in", "Energiescore invoeren, slaapkwaliteit kiezen, opslaan", "Dashboard toont budget en energieniveau"],
|
||||||
|
["Activiteit plannen", "Activiteit aanmaken met naam, categorie en energiepunten", "Energiemeter update direct, activiteit staat in dagoverzicht"],
|
||||||
|
["Activiteit als uitgevoerd markeren", "Activiteit afsluiten met werkelijke duur en vermoeidheidsscore", "Status wijzigt naar uitgevoerd in dagoverzicht"],
|
||||||
|
["Activiteit overslaan", "Skip kiezen met reden", "Status wijzigt naar geskipt, reden opgeslagen"],
|
||||||
|
["Uitloggen", "Uitlogknop", "Redirect naar login, dashboard niet toegankelijk zonder sessie"],
|
||||||
|
["Beveiligde route zonder sessie", "Dashboard-URL bezoeken zonder login", "Redirect naar login"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "8. Testdata-management", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Goede testdata-management voorkomt dat tests elkaar beïnvloeden en maakt tests herhaalbaar. "
|
||||||
|
"De volgende principes gelden:",
|
||||||
|
)
|
||||||
|
bullets(
|
||||||
|
doc,
|
||||||
|
[
|
||||||
|
"Elke E2E-test maakt zijn eigen testgebruiker aan of hergebruikt een dedicated testaccount.",
|
||||||
|
"Unit- en integratietests zijn stateless: ze maken geen gebruik van gedeelde databaserecords.",
|
||||||
|
"Gebruik zod-fixture om valide testfixtures te genereren vanuit Zod-schema's (voorkomt handmatig bijhouden van testobjecten).",
|
||||||
|
"Na integratietests worden aangemaakte records verwijderd (cleanup in afterEach of afterAll).",
|
||||||
|
"Productiedata mag nooit worden gebruikt in tests. Gebruik een aparte Supabase-testomgeving.",
|
||||||
|
"Seed-scripts voor statische referentiedata (activity_categories, skip_reasons) staan in supabase/seed.sql.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "9. CI/CD-integratie", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Tests worden automatisch uitgevoerd in GitHub Actions. "
|
||||||
|
"De CI-pipeline is opgesplitst in twee jobs zodat de snelle unit- en integratietests niet worden vertraagd door E2E-tests.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Job", "Trigger", "Stappen", "Blokkeerend voor merge"],
|
||||||
|
[
|
||||||
|
["Lint en build", "PR en push naar main", "npm ci, npm run lint, npm run build", "Ja"],
|
||||||
|
["Unit en integratie", "PR en push naar main", "npm ci, npx vitest run, supabase test db", "Ja"],
|
||||||
|
["E2E", "PR naar main", "npm ci, npx playwright install, npx playwright test --workers=1", "Ja"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"De omgevingsvariabelen NEXT_PUBLIC_SUPABASE_URL en NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY "
|
||||||
|
"worden als GitHub Actions secrets meegegeven aan de testjobs. "
|
||||||
|
"Gebruik een apart Supabase-testproject zodat testdata de productiedatabase niet verontreinigt.",
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "10. Bestandsstructuur", "Heading 1")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Pad", "Inhoud"],
|
||||||
|
[
|
||||||
|
["lib/checkin/__tests__/budget.test.ts", "Unit tests voor budgetberekening"],
|
||||||
|
["lib/checkin/schemas.ts", "Zod-schema's voor check-in"],
|
||||||
|
["lib/auth/__tests__/navigation.test.ts", "Unit tests voor sanitizeNextPath en buildPathWithQuery"],
|
||||||
|
["lib/auth/__tests__/messages.test.ts", "Unit tests voor getAuthNotice"],
|
||||||
|
["lib/profile/__tests__/service.test.ts", "Integratietests voor profileservice"],
|
||||||
|
["supabase/tests/test_profiles_rls.sql", "pgTAP RLS-tests voor de profiles-tabel"],
|
||||||
|
["supabase/tests/test_user_settings_rls.sql", "pgTAP RLS-tests voor user_settings"],
|
||||||
|
["e2e/auth.spec.ts", "Playwright-tests voor registratie, login en uitloggen"],
|
||||||
|
["e2e/onboarding.spec.ts", "Playwright-tests voor onboardingflow"],
|
||||||
|
["e2e/checkin.spec.ts", "Playwright-tests voor ochtendcheck-in"],
|
||||||
|
["e2e/planning.spec.ts", "Playwright-tests voor activiteiten plannen en evalueren"],
|
||||||
|
["playwright/global-setup.ts", "Programmatische Supabase-login, sessiestate opslaan"],
|
||||||
|
["playwright.config.ts", "Playwright-configuratie inclusief auth-setup en workers"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "11. Acceptatiecriteria per laag", "Heading 1")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Laag", "Minimale eis voor launch"],
|
||||||
|
[
|
||||||
|
["Unit", "Budgetberekening volledig gedekt inclusief grenswaarden. Alle Zod-schema's getest op geldige en ongeldige invoer. Navigatie-utilities getest op open-redirect-preventie."],
|
||||||
|
["Integratie", "Profileservice getest op happy path en bootstrappatroon. Server actions getest op redirect-gedrag bij succes en fout."],
|
||||||
|
["RLS (pgTAP)", "Alle tabellen met gebruikersdata hebben tests voor SELECT, INSERT, UPDATE en DELETE als owner en als andere gebruiker. Unauthenticated access getest."],
|
||||||
|
["E2E", "Login, onboarding, check-in en instellingen zijn geautomatiseerd getest. Beveiligde route zonder sessie redirect naar login."],
|
||||||
|
["Handmatig", "Kernflows geverifieerd op mobiel. Toegankelijkheidscheck op touch targets en contrast. Copy getoetst op niet-medische formulering."],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "12. Bewuste keuzes en afwegingen", "Heading 1")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Keuze", "Alternatief", "Reden voor keuze"],
|
||||||
|
[
|
||||||
|
["Vitest boven Jest", "Jest", "Sneller, native ESM, minder configuratie voor Next.js-projecten in 2026."],
|
||||||
|
["Echte Supabase in integratietests", "Gemockte Supabase-client", "Mocks verbergen RLS- en querygedrag. Echte database geeft meer vertrouwen. Precedent: productie-incident door mock/prod-divergentie."],
|
||||||
|
["pgTAP voor RLS", "Applicatielaag-tests voor RLS", "RLS draait in de database; alleen pgTAP test op het exacte executieniveau."],
|
||||||
|
["Programmatische Playwright-login", "UI-login per test", "Sneller, minder foutgevoelig, vermijdt het testen van hetzelfde auth-pad bij elke test."],
|
||||||
|
["Zod voor validatie", "Handmatige validatiefuncties", "Eén bron van waarheid voor types en validatie. zod-fixture genereert automatisch testdata."],
|
||||||
|
["Geen snapshot tests", "React Testing Library snapshots", "Snapshots zijn fragiel bij kleine UI-wijzigingen en geven weinig semantisch vertrouwen."],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "13. Externe referenties", "Heading 1")
|
||||||
|
references = [
|
||||||
|
("Next.js Testing Guide — Vitest", "https://nextjs.org/docs/app/guides/testing/vitest"),
|
||||||
|
("Next.js Testing Guide — Playwright", "https://nextjs.org/docs/app/guides/testing/playwright"),
|
||||||
|
("Supabase Testing Overview", "https://supabase.com/docs/guides/local-development/testing/overview"),
|
||||||
|
("pgTAP documentatie", "https://pgtap.org/"),
|
||||||
|
("Zod documentatie", "https://zod.dev/"),
|
||||||
|
("zod-fixture — testdata genereren vanuit Zod-schema's", "https://github.com/timdeschryver/zod-fixture"),
|
||||||
|
("Playwright — Supabase auth via REST API", "https://mokkapps.de/blog/login-at-supabase-via-rest-api-in-playwright-e2e-test"),
|
||||||
|
("Playwright — opslaan en hergebruiken van auth-state", "https://playwright.dev/docs/auth"),
|
||||||
|
]
|
||||||
|
for name, url in references:
|
||||||
|
para = doc.add_paragraph(style="List Bullet")
|
||||||
|
para.add_run(f"{name}: ")
|
||||||
|
add_hyperlink(para, url, url)
|
||||||
|
|
||||||
|
set_footer(doc, f"{PRODUCT_NAME} Testplan v0.1")
|
||||||
|
doc.save(BASE_DIR / "inspannings-monitor-07-testplan-v01.docx")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
build_productkader()
|
build_productkader()
|
||||||
|
|
@ -1201,6 +1593,7 @@ def main() -> None:
|
||||||
build_roadmap()
|
build_roadmap()
|
||||||
build_technische_architectuur()
|
build_technische_architectuur()
|
||||||
build_implementatieplan_backlog()
|
build_implementatieplan_backlog()
|
||||||
|
build_testplan()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
375
docs/generate_personas.py
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.shared import Inches, Pt, RGBColor
|
||||||
|
|
||||||
|
BASE_DIR = Path("/Users/janpetervisser/Development/third/docs")
|
||||||
|
PRODUCT_NAME = "Inspannings Monitor"
|
||||||
|
DATE_TEXT = "18 april 2026"
|
||||||
|
|
||||||
|
|
||||||
|
def init_doc(title_text: str, subtitle_text: str) -> Document:
|
||||||
|
doc = Document()
|
||||||
|
section = doc.sections[0]
|
||||||
|
section.top_margin = Inches(0.8)
|
||||||
|
section.bottom_margin = Inches(0.8)
|
||||||
|
section.left_margin = Inches(0.8)
|
||||||
|
section.right_margin = Inches(0.8)
|
||||||
|
|
||||||
|
styles = doc.styles
|
||||||
|
styles["Normal"].font.name = "Aptos"
|
||||||
|
styles["Normal"].font.size = Pt(10.5)
|
||||||
|
for style_name in ["Title", "Subtitle", "Heading 1", "Heading 2", "Heading 3"]:
|
||||||
|
styles[style_name].font.name = "Aptos"
|
||||||
|
styles["Title"].font.size = Pt(22)
|
||||||
|
styles["Subtitle"].font.size = Pt(11)
|
||||||
|
styles["Heading 1"].font.size = Pt(15)
|
||||||
|
styles["Heading 2"].font.size = Pt(12.5)
|
||||||
|
styles["Heading 3"].font.size = Pt(11)
|
||||||
|
styles["Heading 1"].font.bold = True
|
||||||
|
styles["Heading 2"].font.bold = True
|
||||||
|
styles["Heading 3"].font.bold = True
|
||||||
|
|
||||||
|
title = doc.add_paragraph(style="Title")
|
||||||
|
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
title.add_run(title_text)
|
||||||
|
subtitle = doc.add_paragraph(style="Subtitle")
|
||||||
|
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
subtitle.add_run(subtitle_text)
|
||||||
|
doc.add_paragraph("")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def p(doc: Document, text: str) -> None:
|
||||||
|
doc.add_paragraph(text, style="Normal")
|
||||||
|
|
||||||
|
|
||||||
|
def label_value(doc: Document, label: str, value: str) -> None:
|
||||||
|
para = doc.add_paragraph(style="Normal")
|
||||||
|
run_label = para.add_run(f"{label}: ")
|
||||||
|
run_label.bold = True
|
||||||
|
para.add_run(value)
|
||||||
|
|
||||||
|
|
||||||
|
PERSONAS = [
|
||||||
|
{
|
||||||
|
"name": "Marieke van den Berg",
|
||||||
|
"age": 42,
|
||||||
|
"gender": "Vrouw",
|
||||||
|
"ethnicity": "Nederlands",
|
||||||
|
"condition": "Burnout (herstelperiode)",
|
||||||
|
"occupation": "Teamleider bij een gemeente",
|
||||||
|
"location": "Apeldoorn",
|
||||||
|
"story": (
|
||||||
|
"Marieke groeide op in een gezin waar hard werken vanzelfsprekend was. Na haar "
|
||||||
|
"studie Bestuurskunde in Nijmegen begon ze als beleidsmedewerker bij de gemeente "
|
||||||
|
"Apeldoorn. Ze klom snel op. Op haar veertigste leidde ze een team van veertien "
|
||||||
|
"mensen, was ze verantwoordelijk voor drie grote projecten en hielp ze haar moeder "
|
||||||
|
"die net een heupoperatie had gehad.\n\n"
|
||||||
|
"In november 2025 viel ze. Niet letterlijk, maar ze stond op een maandagochtend "
|
||||||
|
"voor de deur van haar kantoor en kon niet naar binnen. Ze belde haar huisarts, "
|
||||||
|
"die haar doorverwees naar een arbeidspsycholoog. Diagnose: ernstige burnout.\n\n"
|
||||||
|
"Sindsdien is Marieke thuis. Ze heeft geleerd dat ze niet weet hoe ze naar haar "
|
||||||
|
"eigen lichaam moet luisteren. Ze rijdt de dag vol met afspraken, ook als ze "
|
||||||
|
"uitgeput is, en merkt het pas als ze op de bank zit en niet meer kan bewegen. "
|
||||||
|
"Haar bedrijfsarts adviseerde haar een dagstructuur op basis van energiebeheer.\n\n"
|
||||||
|
"Marieke is praktisch ingesteld. Ze wil geen dagboek bijhouden — ze wil een "
|
||||||
|
"systeem dat haar helpt zonder dat ze er lang over na hoeft te denken. Ze gebruikt "
|
||||||
|
"haar telefoon veel, maar wantrouwt apps die te veel vragen. Ze wil voortgang zien "
|
||||||
|
"zonder dat het voelt als falen als ze een dag slecht scoort.\n\n"
|
||||||
|
"Haar doel: over zes maanden weer twee dagen per week kunnen werken, zonder terug "
|
||||||
|
"te vallen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Omar El Mansouri",
|
||||||
|
"age": 35,
|
||||||
|
"gender": "Man",
|
||||||
|
"ethnicity": "Marokkaans-Nederlands",
|
||||||
|
"condition": "Long COVID (vermoeidheid en cognitieve klachten)",
|
||||||
|
"occupation": "Zelfstandig bouwondernemer",
|
||||||
|
"location": "Rotterdam",
|
||||||
|
"story": (
|
||||||
|
"Omar is de oudste van vier kinderen. Zijn ouders kwamen in de jaren negentig uit "
|
||||||
|
"Fez naar Rotterdam. Hij heeft zijn bedrijfje in renovatiewerk opgebouwd met zijn "
|
||||||
|
"handen en zijn netwerk: weinig slapende nachten, veel werkende zondagen.\n\n"
|
||||||
|
"In februari 2024 liep hij COVID op. De koorts duurde vijf dagen. Daarna ging het "
|
||||||
|
"niet over. Na drie weken probeerde hij weer te werken. Na een uur op de steiger "
|
||||||
|
"moest hij naar beneden. Zijn hoofd zat vol mist. Zijn handen trilden. Zijn borst "
|
||||||
|
"trok samen als hij een trap opliep.\n\n"
|
||||||
|
"Inmiddels weet hij dat hij long COVID heeft. Hij heeft een fysiotherapeut die hem "
|
||||||
|
"op post-exertionele malaise heeft gewezen: als je te veel doet, crash je en doe je "
|
||||||
|
"dagen terug. Zijn grootste probleem is dat hij dit niet voelt aankomen. Hij denkt "
|
||||||
|
"dat hij het kan, totdat hij het niet meer kan.\n\n"
|
||||||
|
"Omar wil zijn bedrijf niet verliezen. Hij heeft twee vaste medewerkers en een "
|
||||||
|
"lopende hypotheek. Hij zoekt een manier om zijn dagen te plannen zodat hij niet "
|
||||||
|
"elke woensdag knockdown is. Hij wil begrijpen wanneer hij meer en wanneer hij "
|
||||||
|
"minder aankan. Een eenvoudige app op zijn telefoon, die snel in te vullen is en "
|
||||||
|
"niet te veel vraagt, past bij hem."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Priya Naipaul",
|
||||||
|
"age": 51,
|
||||||
|
"gender": "Vrouw",
|
||||||
|
"ethnicity": "Surinaams-Nederlands",
|
||||||
|
"condition": "Fibromyalgie",
|
||||||
|
"occupation": "Leerkracht basisonderwijs (parttime)",
|
||||||
|
"location": "Amsterdam Zuidoost",
|
||||||
|
"story": (
|
||||||
|
"Priya's ouders kwamen uit Suriname naar Nederland in 1980. Ze groeide op in "
|
||||||
|
"Amsterdam Zuidoost, studeerde PABO en geeft al twintig jaar les op een "
|
||||||
|
"basisschool om de hoek. Ze houdt van haar werk, maar de pijn maakt het steeds "
|
||||||
|
"moeilijker.\n\n"
|
||||||
|
"Op haar 46ste kreeg ze na jaren van onverklaarde pijn eindelijk een naam voor "
|
||||||
|
"haar klachten: fibromyalgie. Haar lichaam reageert op stress, slaaptekort en "
|
||||||
|
"overbelasting met hevige pijngolven die haar soms dagenlang in bed houden. Ze "
|
||||||
|
"werkt nu drie dagen per week in plaats van vijf.\n\n"
|
||||||
|
"Priya heeft geleerd dat haar energie per dag sterk wisselt. Op een goede dag "
|
||||||
|
"kan ze boodschappen doen, een les geven en koken. Op een slechte dag is "
|
||||||
|
"douchen al te veel. Ze wil beter voorspellen welke dag wat wordt, zodat ze "
|
||||||
|
"niet steeds haar omgeving teleurstelt met last-minute afzeggingen.\n\n"
|
||||||
|
"Ze is gewend aan apps en gebruikt haar telefoon voor alles. Maar ze heeft "
|
||||||
|
"genoeg gehad van apps die haar het gevoel geven dat ze tekortschiet. Ze "
|
||||||
|
"wil een tool die haar helpt plannen, niet één die haar beoordeelt. Haar "
|
||||||
|
"man en twee volwassen kinderen steunen haar, maar begrijpen niet altijd "
|
||||||
|
"waarom ze de ene dag 'normaal' doet en de andere dag volledig uitvalt."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jeroen Hoekstra",
|
||||||
|
"age": 29,
|
||||||
|
"gender": "Man",
|
||||||
|
"ethnicity": "Nederlands",
|
||||||
|
"condition": "ADHD en angststoornis",
|
||||||
|
"occupation": "Junior softwareontwikkelaar",
|
||||||
|
"location": "Utrecht",
|
||||||
|
"story": (
|
||||||
|
"Jeroen heeft ADHD die op zijn 24ste werd gediagnosticeerd, na jaren van "
|
||||||
|
"uitstellen, vergeten en zichzelf opporren om te functioneren. Hij is slim "
|
||||||
|
"en creatief, maar zijn energiehuishouding is chaotisch: hij kan uren "
|
||||||
|
"hyperfocussen op een codetaak en vergeet dan te eten, of hij is zo "
|
||||||
|
"overprikkeld na een dag vol meetings dat hij 's avonds niks meer kan.\n\n"
|
||||||
|
"Naast zijn ADHD heeft hij een angststoornis die zich uit in piekeren: 's "
|
||||||
|
"nachts, over zijn werk, over of hij genoeg doet, over of zijn collega's hem "
|
||||||
|
"serieus nemen. De combinatie zorgt voor slaapproblemen die zijn overdag-"
|
||||||
|
"functioneren verder ondermijnen.\n\n"
|
||||||
|
"Hij woont alleen in een appartement in Utrecht, werkt hybride bij een "
|
||||||
|
"softwarebedrijf en is in therapie bij een GZ-psycholoog. Zijn therapeut heeft "
|
||||||
|
"hem geadviseerd zijn dag te structureren en momenten van herstel in te "
|
||||||
|
"plannen. Jeroen wil dit doen, maar heeft hulp nodig om het vol te houden.\n\n"
|
||||||
|
"Hij is techsavvy en heeft al tien apps geprobeerd. Hij stopt ermee als ze te "
|
||||||
|
"ingewikkeld zijn, te veel notificaties sturen of te saai zijn. Hij wil een "
|
||||||
|
"app die hem helpt de dag te starten met een helder beeld van wat er reëel "
|
||||||
|
"is, en die hem niet straft als hij van plan verandert."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fatima Yilmaz",
|
||||||
|
"age": 44,
|
||||||
|
"gender": "Vrouw",
|
||||||
|
"ethnicity": "Turks-Nederlands",
|
||||||
|
"condition": "Multiple sclerose (relapsing-remitting)",
|
||||||
|
"occupation": "Freelance vertaalster",
|
||||||
|
"location": "Enschede",
|
||||||
|
"story": (
|
||||||
|
"Fatima is opgegroeid in Enschede als dochter van Turkse gastarbeiders. Ze "
|
||||||
|
"studeerde Talen in Groningen en werkt nu als freelance vertaalster voor "
|
||||||
|
"juridische teksten. Ze is moeder van twee kinderen van 12 en 15 jaar oud.\n\n"
|
||||||
|
"Op haar 38ste kreeg ze de diagnose MS (relapsing-remitting). Sindsdien "
|
||||||
|
"wisselen periodes van relatief goede gezondheid af met aanvallen waarbij "
|
||||||
|
"ze last heeft van extreme vermoeidheid, tintelingen in haar handen en "
|
||||||
|
"wazig zicht. Warmte maakt haar klachten altijd erger.\n\n"
|
||||||
|
"Haar neuroloog heeft haar uitgelegd dat energie bij MS eindig is op een "
|
||||||
|
"heel andere manier dan bij gezonde mensen. Ze heeft het concept 'the "
|
||||||
|
"spoon theory' leren kennen en probeert er zo mee om te gaan. Maar ze "
|
||||||
|
"heeft moeite om de grens aan te voelen voordat ze hem overschrijdt.\n\n"
|
||||||
|
"Fatima werkt vanuit huis, wat flexibel is maar ook lastig: ze heeft geen "
|
||||||
|
"vaste ritme en vraagdruk van klanten bepaalt haar dag. Ze wil een systeem "
|
||||||
|
"dat haar helpt de balans te vinden tussen haar werk, haar gezin en haar "
|
||||||
|
"gezondheid. Ze is goed met computers maar wil geen extra schermen: "
|
||||||
|
"telefoon is makkelijker, zeker als haar handen tintelen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kenneth Asare",
|
||||||
|
"age": 58,
|
||||||
|
"gender": "Man",
|
||||||
|
"ethnicity": "Ghanees-Nederlands",
|
||||||
|
"condition": "Diabetes type 2 met vermoeidheidsklachten",
|
||||||
|
"occupation": "Chauffeur (zit momenteel in ziekteverlof)",
|
||||||
|
"location": "Den Haag",
|
||||||
|
"story": (
|
||||||
|
"Kenneth kwam op zijn 22ste vanuit Ghana naar Nederland om te studeren, maar "
|
||||||
|
"belandde in de logistiek. Hij werkte dertig jaar als chauffeur voor een "
|
||||||
|
"transportbedrijf en was trots op zijn betrouwbaarheid: hij was zelden ziek.\n\n"
|
||||||
|
"Vier jaar geleden werd bij hem diabetes type 2 vastgesteld. Hij paste zijn "
|
||||||
|
"eetpatroon aan, nam zijn medicatie, maar de vermoeidheid bleef. Zijn "
|
||||||
|
"huisarts legde hem uit dat schommelende bloedsuikerspiegels zijn energie "
|
||||||
|
"direct beïnvloeden: soms voelt hij zich prima, soms zit hij na het "
|
||||||
|
"ontbijt al leeg. Na een incident waarbij hij bijna de controle over zijn "
|
||||||
|
"vrachtwagen verloor door hypoglycemie, is hij met verlof gestuurd.\n\n"
|
||||||
|
"Kenneth is niet gewend aan stilzitten. Hij heeft moeite met de "
|
||||||
|
"onzekerheid: welke dagen zijn de slechte? Wanneer kan hij iets plannen "
|
||||||
|
"met zijn kleinkinderen? Hij wil grip terugkrijgen op zijn dag. Hij is "
|
||||||
|
"minder gewend aan smartphones dan jongere gebruikers maar leert snel als "
|
||||||
|
"de interface duidelijk is. Zijn vrouw helpt hem soms, maar hij wil zelf "
|
||||||
|
"in controle zijn.\n\n"
|
||||||
|
"Zijn doel is om te begrijpen wat zijn energiepatronen zijn, zodat hij "
|
||||||
|
"straks — als hij wellicht deeltijd kan hervatten — weet wanneer hij wat "
|
||||||
|
"aankan."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lisa Vermeulen",
|
||||||
|
"age": 23,
|
||||||
|
"gender": "Vrouw",
|
||||||
|
"ethnicity": "Nederlands",
|
||||||
|
"condition": "ME/CVS (chronisch vermoeidheidssyndroom)",
|
||||||
|
"occupation": "Voormalig student (studie gestaakt)",
|
||||||
|
"location": "Breda",
|
||||||
|
"story": (
|
||||||
|
"Lisa studeerde Psychologie in Tilburg toen ze ziek werd, twee jaar geleden. "
|
||||||
|
"Wat begon als een virale infectie, werd nooit beter. Na negen maanden "
|
||||||
|
"van steeds teruggaan naar de huisarts met klachten die ze niet goed kon "
|
||||||
|
"verwoorden, werd ME/CVS vastgesteld.\n\n"
|
||||||
|
"Ze heeft haar studie moeten staken. Ze woont weer bij haar ouders in Breda "
|
||||||
|
"omdat ze niet voor zichzelf kan zorgen op drukke dagen. Op goede dagen "
|
||||||
|
"maakt ze een kleine wandeling en kan ze een uur achter haar laptop zitten. "
|
||||||
|
"Op slechte dagen is ze bedlegerig en kan ze het licht en geluid in haar "
|
||||||
|
"kamer nauwelijks verdragen.\n\n"
|
||||||
|
"Lisa's grootste uitdaging is de onzichtbaarheid van haar ziekte. Ze ziet "
|
||||||
|
"er uit als een gezonde 23-jarige. Vrienden begrijpen niet waarom ze niet "
|
||||||
|
"naar feestjes kan. Ze heeft geleerd dat post-exertionele malaise haar "
|
||||||
|
"grootste vijand is: te veel doen kost haar dagenlang herstel.\n\n"
|
||||||
|
"Ze wil grip krijgen op haar beschikbare energie. Niet om meer te doen, "
|
||||||
|
"maar om beter te verdelen wat ze heeft. Ze is vertrouwd met technologie "
|
||||||
|
"en heeft haar telefoon vlakbij, maar heeft apps nodig die snel te "
|
||||||
|
"gebruiken zijn — lange formulieren zijn op slechte dagen onmogelijk. "
|
||||||
|
"Ze wil ook haar eigen data kunnen terugzien, zodat ze het gesprek met "
|
||||||
|
"haar behandelaars beter kan voeren."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sander Tan",
|
||||||
|
"age": 47,
|
||||||
|
"gender": "Man",
|
||||||
|
"ethnicity": "Indonesisch-Nederlands",
|
||||||
|
"condition": "Depressie (recidiverend) en slaapstoornis",
|
||||||
|
"occupation": "Grafisch ontwerper (deeltijd)",
|
||||||
|
"location": "Haarlem",
|
||||||
|
"story": (
|
||||||
|
"Sander is de kleinzoon van Indische Nederlanders die na de dekolonisatie "
|
||||||
|
"naar Nederland kwamen. Hij groeide op in Haarlem in een gezin dat weinig "
|
||||||
|
"sprak over emoties of gezondheid. Hij werkte jarenlang als grafisch "
|
||||||
|
"ontwerper voor reclamebureaus, creatief en productief, totdat zijn eerste "
|
||||||
|
"depressie hem op zijn 38ste volledig lam legde.\n\n"
|
||||||
|
"Sindsdien heeft hij drie depressieve episodes doorgemaakt. Tussen de "
|
||||||
|
"episodes door werkt hij parttime, maar hij voelt zich nooit helemaal "
|
||||||
|
"stabiel. Hij slaapt slecht: soms te weinig, soms te lang, zelden "
|
||||||
|
"herstellend. De slaaptekort versterkt zijn stemming negatief en omgekeerd.\n\n"
|
||||||
|
"Zijn psychiater heeft hem geleerd dat bij depressie de energiehuishouding "
|
||||||
|
"verstoord is: zijn motivatie en zijn vermogen zijn niet altijd in balans. "
|
||||||
|
"Hij heeft moeite om te beginnen aan dingen, zelfs als hij weet dat ze "
|
||||||
|
"hem goed doen. Maar als hij begint, kan hij soms te lang doorgaan en dan "
|
||||||
|
"crasht hij de volgende dag.\n\n"
|
||||||
|
"Sander zoekt een lichte structuur voor zijn dag. Niet te dwingend — dat "
|
||||||
|
"verhoogt zijn angst — maar genoeg houvast om niet de hele dag te "
|
||||||
|
"improviseren. Hij is visueel ingesteld en waardeert een heldere, "
|
||||||
|
"rustige interface. Hij wil zien hoe hij het gedaan heeft, maar niet "
|
||||||
|
"worden overspoeld met grafieken. Eén duidelijk getal is genoeg."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
FACE_IMAGES = [f"/tmp/persona_face_{i}.jpg" for i in range(1, 9)]
|
||||||
|
|
||||||
|
|
||||||
|
def add_photo(doc: Document, image_path: str) -> None:
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
if not _Path(image_path).exists():
|
||||||
|
return
|
||||||
|
para = doc.add_paragraph()
|
||||||
|
para.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||||
|
run = para.add_run()
|
||||||
|
run.add_picture(image_path, width=Inches(1.5))
|
||||||
|
|
||||||
|
|
||||||
|
def build_personas():
|
||||||
|
doc = init_doc(
|
||||||
|
f"{PRODUCT_NAME} — Gebruikerspersonas",
|
||||||
|
f"Fictieve gebruikers voor ontwerp en testvalidatie · {DATE_TEXT}",
|
||||||
|
)
|
||||||
|
|
||||||
|
doc.add_heading("Inleiding", level=1)
|
||||||
|
p(doc, (
|
||||||
|
"Dit document beschrijft acht fictieve gebruikers van de Inspannings Monitor. "
|
||||||
|
"Ze zijn ontworpen om de diversiteit van de doelgroep te vertegenwoordigen: "
|
||||||
|
"volwassenen die te maken hebben met energiebeheer als gevolg van uiteenlopende "
|
||||||
|
"gezondheidsklachten. De personas zijn fictief maar gebaseerd op reële ziektebeelden "
|
||||||
|
"en ervaringen die relevant zijn voor de positionering van het product als "
|
||||||
|
"wellness/self-management tool."
|
||||||
|
))
|
||||||
|
p(doc, (
|
||||||
|
"De personas zijn verdeeld naar geslacht (4 vrouwen, 4 mannen), etniciteit "
|
||||||
|
"(Nederlandse achtergrond, Marokkaans-Nederlands, Turks-Nederlands, Surinaams-"
|
||||||
|
"Nederlands, Ghanees-Nederlands, Indonesisch-Nederlands) en ziektebeeld "
|
||||||
|
"(burnout, long COVID, fibromyalgie, ADHD/angst, MS, diabetes type 2, ME/CVS, "
|
||||||
|
"depressie/slaapstoornis)."
|
||||||
|
))
|
||||||
|
p(doc, (
|
||||||
|
"Gebruik: de personas dienen als referentiekader voor UX-beslissingen, "
|
||||||
|
"testscenario's en prioritering van functies. Ze zijn geen medische profielen "
|
||||||
|
"en vormen geen basis voor medische claims."
|
||||||
|
))
|
||||||
|
|
||||||
|
doc.add_heading("Overzicht", level=1)
|
||||||
|
from docx.oxml.ns import qn as _qn
|
||||||
|
from docx.oxml import OxmlElement as _OxmlElement
|
||||||
|
|
||||||
|
table = doc.add_table(rows=1, cols=5)
|
||||||
|
table.style = "Table Grid"
|
||||||
|
hdr = table.rows[0].cells
|
||||||
|
for i, h in enumerate(["#", "Naam", "Geslacht / leeftijd", "Achtergrond", "Ziektebeeld"]):
|
||||||
|
hdr[i].text = h
|
||||||
|
hdr[i].paragraphs[0].runs[0].bold = True
|
||||||
|
|
||||||
|
for i, persona in enumerate(PERSONAS, start=1):
|
||||||
|
row = table.add_row().cells
|
||||||
|
row[0].text = str(i)
|
||||||
|
row[1].text = persona["name"]
|
||||||
|
row[2].text = f"{persona['gender']}, {persona['age']}"
|
||||||
|
row[3].text = persona["ethnicity"]
|
||||||
|
row[4].text = persona["condition"]
|
||||||
|
|
||||||
|
doc.add_paragraph("")
|
||||||
|
|
||||||
|
doc.add_heading("Gebruikerspersonas", level=1)
|
||||||
|
|
||||||
|
for i, persona in enumerate(PERSONAS, start=1):
|
||||||
|
doc.add_heading(f"{i}. {persona['name']}", level=2)
|
||||||
|
|
||||||
|
add_photo(doc, FACE_IMAGES[i - 1])
|
||||||
|
|
||||||
|
label_value(doc, "Leeftijd", str(persona["age"]))
|
||||||
|
label_value(doc, "Geslacht", persona["gender"])
|
||||||
|
label_value(doc, "Etniciteit", persona["ethnicity"])
|
||||||
|
label_value(doc, "Beroep", persona["occupation"])
|
||||||
|
label_value(doc, "Woonplaats", persona["location"])
|
||||||
|
label_value(doc, "Ziektebeeld", persona["condition"])
|
||||||
|
|
||||||
|
doc.add_heading("Levensverhaal", level=3)
|
||||||
|
for paragraph in persona["story"].split("\n\n"):
|
||||||
|
p(doc, paragraph.strip())
|
||||||
|
|
||||||
|
doc.add_paragraph("")
|
||||||
|
|
||||||
|
out_path = BASE_DIR / "inspannings-monitor-08-gebruikerspersonas-v01.docx"
|
||||||
|
doc.save(out_path)
|
||||||
|
print(f"Opgeslagen: {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build_personas()
|
||||||
694
docs/generate_testplan.py
Normal file
|
|
@ -0,0 +1,694 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.opc.constants import RELATIONSHIP_TYPE as RT
|
||||||
|
from docx.shared import Inches, Pt
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path("/Users/janpetervisser/Development/third/docs")
|
||||||
|
PRODUCT_NAME = "Inspannings Monitor"
|
||||||
|
DATE_TEXT = "18 april 2026"
|
||||||
|
POSITIONING = "Wellness/self-management"
|
||||||
|
HOSTING = "Vercel"
|
||||||
|
DATABASE = "Supabase PostgreSQL"
|
||||||
|
AUTH = "Supabase Auth"
|
||||||
|
|
||||||
|
|
||||||
|
def init_doc(title_text: str, subtitle_text: str) -> Document:
|
||||||
|
doc = Document()
|
||||||
|
section = doc.sections[0]
|
||||||
|
section.top_margin = Inches(0.8)
|
||||||
|
section.bottom_margin = Inches(0.8)
|
||||||
|
section.left_margin = Inches(0.8)
|
||||||
|
section.right_margin = Inches(0.8)
|
||||||
|
|
||||||
|
styles = doc.styles
|
||||||
|
styles["Normal"].font.name = "Aptos"
|
||||||
|
styles["Normal"].font.size = Pt(10.5)
|
||||||
|
for style_name in ["Title", "Subtitle", "Heading 1", "Heading 2", "Heading 3"]:
|
||||||
|
styles[style_name].font.name = "Aptos"
|
||||||
|
styles["Title"].font.size = Pt(22)
|
||||||
|
styles["Subtitle"].font.size = Pt(11)
|
||||||
|
styles["Heading 1"].font.size = Pt(15)
|
||||||
|
styles["Heading 2"].font.size = Pt(12.5)
|
||||||
|
styles["Heading 3"].font.size = Pt(11)
|
||||||
|
styles["Heading 1"].font.bold = True
|
||||||
|
styles["Heading 2"].font.bold = True
|
||||||
|
styles["Heading 3"].font.bold = True
|
||||||
|
|
||||||
|
title = doc.add_paragraph(style="Title")
|
||||||
|
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
title.add_run(title_text)
|
||||||
|
subtitle = doc.add_paragraph(style="Subtitle")
|
||||||
|
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
subtitle.add_run(subtitle_text)
|
||||||
|
doc.add_paragraph("")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def add_hyperlink(paragraph, text: str, url: str) -> None:
|
||||||
|
part = paragraph.part
|
||||||
|
rel_id = part.relate_to(url, RT.HYPERLINK, is_external=True)
|
||||||
|
hyperlink = OxmlElement("w:hyperlink")
|
||||||
|
hyperlink.set(qn("r:id"), rel_id)
|
||||||
|
run = OxmlElement("w:r")
|
||||||
|
run_props = OxmlElement("w:rPr")
|
||||||
|
color = OxmlElement("w:color")
|
||||||
|
color.set(qn("w:val"), "0563C1")
|
||||||
|
run_props.append(color)
|
||||||
|
underline = OxmlElement("w:u")
|
||||||
|
underline.set(qn("w:val"), "single")
|
||||||
|
run_props.append(underline)
|
||||||
|
run.append(run_props)
|
||||||
|
text_elem = OxmlElement("w:t")
|
||||||
|
text_elem.text = text
|
||||||
|
run.append(text_elem)
|
||||||
|
hyperlink.append(run)
|
||||||
|
paragraph._p.append(hyperlink)
|
||||||
|
|
||||||
|
|
||||||
|
def p(doc: Document, text: str = "", style: str = "Normal") -> None:
|
||||||
|
doc.add_paragraph(text, style=style)
|
||||||
|
|
||||||
|
|
||||||
|
def bullets(doc: Document, items) -> None:
|
||||||
|
for item in items:
|
||||||
|
doc.add_paragraph(item, style="List Bullet")
|
||||||
|
|
||||||
|
|
||||||
|
def numbered(doc: Document, items) -> None:
|
||||||
|
for item in items:
|
||||||
|
doc.add_paragraph(item, style="List Number")
|
||||||
|
|
||||||
|
|
||||||
|
def table(doc: Document, headers, rows) -> None:
|
||||||
|
tbl = doc.add_table(rows=1, cols=len(headers))
|
||||||
|
tbl.style = "Table Grid"
|
||||||
|
for idx, header in enumerate(headers):
|
||||||
|
cell = tbl.rows[0].cells[idx]
|
||||||
|
cell.text = header
|
||||||
|
for para in cell.paragraphs:
|
||||||
|
for run in para.runs:
|
||||||
|
run.bold = True
|
||||||
|
for row in rows:
|
||||||
|
cells = tbl.add_row().cells
|
||||||
|
for idx, value in enumerate(row):
|
||||||
|
cells[idx].text = value
|
||||||
|
doc.add_paragraph("")
|
||||||
|
|
||||||
|
|
||||||
|
def set_footer(doc: Document, text: str) -> None:
|
||||||
|
footer = doc.sections[0].footer.paragraphs[0]
|
||||||
|
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
footer.text = text
|
||||||
|
|
||||||
|
|
||||||
|
def build_testplan() -> None:
|
||||||
|
doc = init_doc(
|
||||||
|
f"{PRODUCT_NAME} Testplan v0.2",
|
||||||
|
f"Risicogebaseerde teststrategie, traceability, cybersecurity en QMS-fundament\n{DATE_TEXT}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 1. Documentdoel ──────────────────────────────────────────────────────
|
||||||
|
p(doc, "1. Documentdoel", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
f"Dit document beschrijft hoe {PRODUCT_NAME} getest wordt. De strategie is verschoven van "
|
||||||
|
"'werkt de feature?' naar 'is het risico beheerst?'. "
|
||||||
|
"Dat onderscheid is relevant zelfs voor een wellness-first product: de app verwerkt gezondheidsgerelateerde "
|
||||||
|
"gegevens van mensen met chronische vermoeidheid, en een fout in de budgetberekening of een lek in de "
|
||||||
|
"datatoegang kan directe gevolgen hebben voor het vertrouwen en zelfmanagement van de gebruiker. "
|
||||||
|
"Dit document combineert risicoanalyse (ISO 14971-principe), verificatie versus validatie, "
|
||||||
|
"een traceability matrix, cybersecurity testing (NEN 7510), ISO 13485-voorbereiding "
|
||||||
|
"en de praktische testpiramide met tooling.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 2. Van feature-test naar risicobeheer ────────────────────────────────
|
||||||
|
p(doc, "2. Teststrategie: van feature-test naar risicobeheer", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Traditionele teststrategie stelt de vraag: 'doet de code wat de developer verwacht?' "
|
||||||
|
"Een risicogebaseerde aanpak stelt de vraag: 'wat zijn de gevolgen als deze functie faalt, en zijn die gevolgen acceptabel?' "
|
||||||
|
"De testinspanning wordt verdeeld op basis van de risicoscore van een functie, niet op basis van complexiteit of backlog-prioriteit.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Risicoscore", "Betekenis", "Vereiste testdekking"],
|
||||||
|
[
|
||||||
|
["Kritiek", "Fout leidt tot verkeerde zelfmanagementbeslissing of datadiefstal", "100% — unit, integratie én E2E verplicht"],
|
||||||
|
["Hoog", "Fout leidt tot onjuiste weergave of verlies van gebruikersdata", "Volledige unit- en integratietestdekking, E2E aanbevolen"],
|
||||||
|
["Middel", "Fout leidt tot verminderd gebruiksgemak of niet-kritieke weergavefout", "Minimaal unit tests op grenzen, handmatige verificatie"],
|
||||||
|
["Laag", "Cosmetische of zeldzame randgevallen zonder impact op data of beslissingen", "Handmatige QA voldoende"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 3. Verificatie versus validatie ──────────────────────────────────────
|
||||||
|
p(doc, "3. Verificatie en validatie", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Dit onderscheid is cruciaal bij gezondheidsgerelateerde software en vormt de basis voor "
|
||||||
|
"MDR-documentatieplicht en ISO 13485-conformiteit zodra het product richting een medisch track gaat. "
|
||||||
|
"Ook voor de huidige wellness-first versie geldt dit als kwalitatief fundament.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Type", "Centrale vraag", "Activiteiten", "Wie"],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"Verificatie",
|
||||||
|
"Hebben we het product goed gebouwd?",
|
||||||
|
"Unit tests, integratietests, RLS-tests, code review, statische analyse, traceability matrix",
|
||||||
|
"Engineers",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Validatie",
|
||||||
|
"Hebben we het juiste product gebouwd voor de gebruiker?",
|
||||||
|
"Usability tests met echte gebruikers, acceptatietests op functionele requirements, klinische evaluatie (toekomstig medisch track)",
|
||||||
|
"Product, UX, (toekomstig) klinisch evaluator",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Validatie is niet hetzelfde als E2E-testen. Een geautomatiseerde Playwright-test verifieert dat de app "
|
||||||
|
"technisch correct werkt, maar valideert niet of de gebruiker de energieslider begrijpt of de juiste "
|
||||||
|
"beslissing neemt op basis van weergegeven informatie. Usability tests met echte gebruikers zijn nodig "
|
||||||
|
"voor validatie — dit is een geplande activiteit vóór launch (ST-801).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 4. ISO 13485 en QMS-voorbereiding ────────────────────────────────────
|
||||||
|
p(doc, "4. ISO 13485 en kwaliteitsmanagementsysteem (QMS)", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"ISO 13485 is de internationale norm voor kwaliteitsmanagementsystemen voor medische hulpmiddelen. "
|
||||||
|
f"In de huidige wellness-first fase is {PRODUCT_NAME} geen medisch hulpmiddel en is ISO 13485-certificering "
|
||||||
|
"niet vereist. De norm wordt hier als leidraad gebruikt om de kwaliteitsborging al in de goede richting "
|
||||||
|
"op te zetten, zodat de drempel naar een eventuele medische track zo laag mogelijk blijft.",
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "4.1 Relevante ISO 13485-vereisten als leidraad", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["ISO 13485-element", "Toepassing in wellness-first fase", "Status"],
|
||||||
|
[
|
||||||
|
["Documentbeheer (par. 4.2)", "Alle specificaties, testplannen en besluitlogs worden beheerd en voorzien van versienummer en datum", "Ingericht via docs/ en git"],
|
||||||
|
["Risicomanagement (par. 7.1, ref. ISO 14971)", "Risicoanalyse per functie uitgevoerd (zie sectie 5)", "In dit document"],
|
||||||
|
["Software-ontwikkelingsproces (par. 7.3)", "Backlog, epics, definition of done en code review zijn aanwezig", "Ingericht via Linear en GitHub"],
|
||||||
|
["Verificatie en validatie (par. 7.3.6 / 7.3.7)", "Testpiramide met traceability matrix per requirement", "In dit document"],
|
||||||
|
["Traceability (par. 7.3.4)", "Traceability matrix koppelt elke FR aan een test en resultaat", "Sectie 6 van dit document"],
|
||||||
|
["Correctieve maatregelen / CAPA (par. 8.5)", "Bugs worden bijgehouden in Linear; ernstige fouten krijgen een root-cause analyse", "Aanbevolen werkwijze"],
|
||||||
|
["Interne audit (par. 8.2.2)", "Periodieke review van testresultaten en traceability matrix vóór elke release", "Aanbevolen als releasepoort"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "4.2 Wanneer is formele ISO 13485-certificering nodig?", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Formele certificering is verplicht wanneer het product wordt geclassificeerd als medisch hulpmiddel "
|
||||||
|
"onder de EU Medical Device Regulation (MDR 2017/745). "
|
||||||
|
"De decision gate hiervoor staat beschreven in het Roadmap-document (doc 04, Gate D en E). "
|
||||||
|
"Zolang het product binnen de wellness-positionering blijft, is certificering niet vereist maar is "
|
||||||
|
"de gestructureerde aanpak uit dit testplan voldoende als kwaliteitsborging.",
|
||||||
|
)
|
||||||
|
bullets(
|
||||||
|
doc,
|
||||||
|
[
|
||||||
|
"Stel nu al een documentregister in (versies, goedkeuringen, archief) — dit is de kern van elk QMS.",
|
||||||
|
"Documenteer afwijkingen en bugs als 'non-conformities' in Linear met root-cause en correctieve actie.",
|
||||||
|
"Voer vóór elke release een interne review uit van de traceability matrix en de testresultaten.",
|
||||||
|
"Zodra het product richting medisch track gaat: stel een formeel QMS-handboek op en start een gap-analyse tegen ISO 13485.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 5. Risicoanalyse per functie (ISO 14971) ─────────────────────────────
|
||||||
|
p(doc, "5. Risicoanalyse per functie (ISO 14971-principe)", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Voor elke kritieke functie is bepaald wat de ernstigste consequentie van een fout is, "
|
||||||
|
"wat de risicoscore is en welke testdekking verplicht is. "
|
||||||
|
"Risico = kans op optreden × ernst van de gevolgen. "
|
||||||
|
"Kans wordt bepaald door code-complexiteit en het ontbreken van type-veiligheid of validatie.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Functie / module", "Ernstigste fout", "Ernst", "Kans zonder tests", "Risicoscore", "Vereiste dekking"],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"Budgetberekening (score → energyLevel + dailyBudget)",
|
||||||
|
"Gebruiker krijgt verkeerd budget; plant meer dan verantwoord is",
|
||||||
|
"Hoog",
|
||||||
|
"Middel",
|
||||||
|
"Kritiek",
|
||||||
|
"100% unit tests op alle grenswaarden + integratietest op opgeslagen output",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"RLS-beleid (owner-only datatoegang)",
|
||||||
|
"Gebruiker A leest of overschrijft data van gebruiker B",
|
||||||
|
"Kritiek",
|
||||||
|
"Middel",
|
||||||
|
"Kritiek",
|
||||||
|
"100% pgTAP-tests op alle tabellen, alle operaties, alle rollen",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Authenticatie en sessiebeheer",
|
||||||
|
"Niet-ingelogde gebruiker krijgt toegang tot dashboard of data",
|
||||||
|
"Hoog",
|
||||||
|
"Laag",
|
||||||
|
"Hoog",
|
||||||
|
"E2E-test op elke beveiligde route; integratietest op getAuthState()",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Zod-invoervalidatie (server actions)",
|
||||||
|
"Ongeldige of kwaadaardige invoer bereikt de database",
|
||||||
|
"Hoog",
|
||||||
|
"Middel",
|
||||||
|
"Hoog",
|
||||||
|
"Unit tests op schema-grenzen; integratietest op server action met ongeldige invoer",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Insightregels en weekpatronen",
|
||||||
|
"Gebruiker trekt verkeerde conclusie op basis van onjuist patroon",
|
||||||
|
"Middel",
|
||||||
|
"Middel",
|
||||||
|
"Hoog",
|
||||||
|
"Unit tests op aggregatiefuncties; datadrempellogica getest op minimum-threshold",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Reflectieprompt-planning (T+1/T+2 job)",
|
||||||
|
"Dubbele of gemiste prompts",
|
||||||
|
"Laag",
|
||||||
|
"Middel",
|
||||||
|
"Middel",
|
||||||
|
"Integratietest op idempotentie van joblogica",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Navigatie-sanitisatie (open redirect)",
|
||||||
|
"Gebruiker wordt doorgestuurd naar externe kwaadaardige URL",
|
||||||
|
"Hoog",
|
||||||
|
"Laag",
|
||||||
|
"Hoog",
|
||||||
|
"Unit tests inclusief double-slash en externe URL-patronen",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Copy en insighttekst (wellness vs. medisch)",
|
||||||
|
"Regulatoire interpretatie als medisch hulpmiddel",
|
||||||
|
"Hoog (regulatoir)",
|
||||||
|
"Middel",
|
||||||
|
"Hoog",
|
||||||
|
"Handmatige copyreview vóór elke release; geautomatiseerde woordlijstcheck overwegen",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 6. Traceability matrix ───────────────────────────────────────────────
|
||||||
|
p(doc, "6. Traceability matrix", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"De traceability matrix koppelt elke functionele requirement (FR-ID) aan de tests die aantonen dat de eis "
|
||||||
|
"is geverifieerd. Dit is een kernvereiste voor MDR-conformiteit, ISO 13485 (par. 7.3.4) en voor "
|
||||||
|
"auditeerbare kwaliteitsborging. De matrix wordt bij elke release bijgewerkt met het testresultaat. "
|
||||||
|
"Mislukte tests blokkeren de release. Overgeslagen tests vereisen een expliciete risicoafweging.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Requirement ID", "Omschrijving (kort)", "Testtype", "Test ID / bestand", "Risicoscore", "Resultaat"],
|
||||||
|
[
|
||||||
|
["FR-CHK-001", "Check-in opslaan met energiescore en slaapkwaliteit", "E2E", "e2e/checkin.spec.ts — happy path", "Middel", "—"],
|
||||||
|
["FR-CHK-002", "Dagbudget afleiden uit energiescore", "Unit + Integratie", "lib/checkin/__tests__/budget.test.ts", "Kritiek", "—"],
|
||||||
|
["FR-PLAN-001", "Activiteit plannen met verplichte velden", "E2E", "e2e/planning.spec.ts — aanmaken", "Middel", "—"],
|
||||||
|
["FR-PLAN-002", "Lopend totaal bijwerken na mutatie", "Integratie + E2E", "lib/planning/__tests__/meter.test.ts", "Hoog", "—"],
|
||||||
|
["FR-PLAN-003", "Niet-blokkerende waarschuwing bij overschrijding", "E2E", "e2e/planning.spec.ts — budget overschrijden", "Middel", "—"],
|
||||||
|
["FR-ACT-001", "Activiteit als uitgevoerd markeren", "E2E", "e2e/planning.spec.ts — uitgevoerd", "Middel", "—"],
|
||||||
|
["FR-ACT-002", "Activiteit als geskipt markeren met reden", "E2E", "e2e/planning.spec.ts — geskipt", "Middel", "—"],
|
||||||
|
["FR-ACT-003", "Activiteit als aangepast markeren", "E2E", "e2e/planning.spec.ts — aangepast", "Middel", "—"],
|
||||||
|
["FR-ACT-005", "Ongeplande activiteit toevoegen", "Integratie + E2E", "lib/planning/__tests__/service.test.ts", "Middel", "—"],
|
||||||
|
["FR-DAY-001", "Gepland versus uitgevoerd tonen in dagoverzicht", "E2E", "e2e/planning.spec.ts — dagoverzicht", "Middel", "—"],
|
||||||
|
["FR-WEEK-001", "Weekoverzicht met gemiddelde energie en adherence", "Unit + Integratie", "lib/insights/__tests__/week.test.ts", "Hoog", "—"],
|
||||||
|
["FR-INS-001", "Inzicht alleen tonen bij minimale data", "Unit", "lib/insights/__tests__/thresholds.test.ts", "Hoog", "—"],
|
||||||
|
["FR-REM-001", "Reflectieprompts per gebruiker aan/uit", "Integratie", "lib/reflection/__tests__/service.test.ts", "Middel", "—"],
|
||||||
|
["FR-SET-001", "Instellingen opslaan en direct actief", "E2E", "e2e/settings.spec.ts", "Middel", "—"],
|
||||||
|
["SEC-001", "TLS op alle communicatie", "Handmatig / infra", "SSL Labs check op productiedomain", "Hoog", "—"],
|
||||||
|
["SEC-004", "Rate limiting op auth-routes", "Integratie", "lib/auth/__tests__/ratelimit.test.ts", "Hoog", "—"],
|
||||||
|
["SEC-RLS-001", "Owner-only SELECT op profiles", "pgTAP", "supabase/tests/test_profiles_rls.sql", "Kritiek", "—"],
|
||||||
|
["SEC-RLS-002", "Owner-only SELECT op user_settings", "pgTAP", "supabase/tests/test_user_settings_rls.sql", "Kritiek", "—"],
|
||||||
|
["SEC-RLS-003", "Owner-only SELECT op morning_check_ins", "pgTAP", "supabase/tests/test_morning_check_ins_rls.sql", "Kritiek", "—"],
|
||||||
|
["SEC-RLS-004", "Owner-only SELECT op activities", "pgTAP", "supabase/tests/test_activities_rls.sql", "Kritiek", "—"],
|
||||||
|
["SAFE-001", "Geen medische taal in UI-copy", "Handmatig copyreview", "Checklist ST-803", "Hoog", "—"],
|
||||||
|
["SAFE-003", "Inzichten tonen minimale-dataguardrail", "Unit", "lib/insights/__tests__/thresholds.test.ts", "Hoog", "—"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 7. Cybersecurity testing (NEN 7510) ──────────────────────────────────
|
||||||
|
p(doc, "7. Cybersecurity testing (NEN 7510)", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"NEN 7510 is de Nederlandse norm voor informatiebeveiliging in de zorg. "
|
||||||
|
f"Hoewel {PRODUCT_NAME} een wellness-product is, verwerkt de app gezondheidsgerelateerde persoonsgegevens. "
|
||||||
|
"De NEN 7510-baseline wordt als toetssteen gebruikt om privacyrisico's te beheersen en de "
|
||||||
|
"drempel naar een toekomstige medische track te verlagen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "7.1 Encryptie en datatransport", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Eis", "Norm", "Testmethode", "Acceptatiecriterium"],
|
||||||
|
[
|
||||||
|
["TLS 1.2 of hoger op alle routes", "NEN 7510 / SEC-001", "SSL Labs op productiedomain", "A-rating, geen TLS 1.0/1.1"],
|
||||||
|
["Data at rest versleuteld (AES-256)", "NEN 7510 / SEC-002", "Supabase-dashboard encryptie-instellingen", "AES-256 bevestigd"],
|
||||||
|
["Geen gevoelige data in URL-parameters", "OWASP / NEN 7510", "Handmatige review alle redirects", "Geen tokens of gezondheidsdata in URL"],
|
||||||
|
["HTTPS-only, geen mixed content", "SEC-001", "Content-Security-Policy header check", "Geen HTTP-requests in productie"],
|
||||||
|
["Veilige cookie-attributen", "OWASP Session Management", "Browser-devtools of Playwright-test", "Secure, HttpOnly, SameSite aanwezig"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "7.2 Authenticatie en toegangscontrole", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Eis", "Norm", "Testmethode", "Acceptatiecriterium"],
|
||||||
|
[
|
||||||
|
["Brute-force bescherming op login", "NEN 7510 / SEC-004", "Integratietest: >10 snelle pogingen triggert rate limit", "429-respons of vertraging"],
|
||||||
|
["Sessie vervalt na inactiviteit", "NEN 7510 / SEC-003", "Handmatig: sessie na 24 uur controleren", "Gebruiker moet opnieuw inloggen"],
|
||||||
|
["Geen sessietokens in localStorage", "OWASP", "Browser-devtools na login", "Geen tokens in localStorage"],
|
||||||
|
["Beveiligde routes zonder sessie", "SEC-003", "Playwright-test: dashboard zonder cookie", "Redirect naar /login"],
|
||||||
|
["Owner-only datatoegang (RLS)", "NEN 7510 / SEC-002", "pgTAP-tests op alle tabellen", "Zie traceability SEC-RLS-001 t/m 004"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "7.3 Penetratietest", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Testvorm", "Scope", "Timing", "Uitvoerder"],
|
||||||
|
[
|
||||||
|
["Geautomatiseerde OWASP Top 10 scan", "Alle publieke en beveiligde routes", "Vóór launch R1", "OWASP ZAP of Burp Suite Community"],
|
||||||
|
["Handmatig: SQL-injectie via formulieren", "Alle invoervelden die server actions aanroepen", "Vóór launch R1", "Engineer of security reviewer"],
|
||||||
|
["Handmatig: IDOR (cross-user data access)", "Alle calls met user-specifieke IDs", "Vóór launch R1", "Engineer"],
|
||||||
|
["Formele pentest door externe partij", "Volledige applicatie", "Vóór medische track", "Gecertificeerde pentest-partij"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"IDOR-testprocedure: log in als gebruiker A, kopieer een record-ID (bijv. activity ID), "
|
||||||
|
"log in als gebruiker B en probeer dat record op te halen of te muteren via directe API-aanroep. "
|
||||||
|
"Verwacht resultaat: 404 of 403, nooit de data van gebruiker A.",
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "7.4 Security headers", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Header", "Aanbevolen waarde", "Testmethode"],
|
||||||
|
[
|
||||||
|
["Content-Security-Policy", "Strikte policy, inline scripts beperkt", "securityheaders.com"],
|
||||||
|
["Strict-Transport-Security", "max-age=31536000; includeSubDomains", "curl -I op productiedomain"],
|
||||||
|
["X-Frame-Options", "DENY of SAMEORIGIN", "securityheaders.com"],
|
||||||
|
["X-Content-Type-Options", "nosniff", "securityheaders.com"],
|
||||||
|
["Referrer-Policy", "strict-origin-when-cross-origin", "securityheaders.com"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
p(doc, "Security headers worden geconfigureerd in next.config.ts via de headers()-functie.", "Normal")
|
||||||
|
|
||||||
|
p(doc, "7.5 Logging en auditability (NEN 7510)", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Te loggen event", "Minimale informatie", "Testmethode"],
|
||||||
|
[
|
||||||
|
["Mislukte loginpoging", "Tijdstip, IP, e-mailadres (geanonimiseerd)", "Integratietest: mislukte login triggert logentry"],
|
||||||
|
["Succesvolle login", "Tijdstip, userId", "Integratietest: succesvolle login triggert logentry"],
|
||||||
|
["Accountverwijdering", "Tijdstip, userId", "Handmatige verificatie"],
|
||||||
|
["Sessie-timeout", "Tijdstip, userId", "Handmatige verificatie"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 8. Testpiramide en tooling ───────────────────────────────────────────
|
||||||
|
p(doc, "8. Testpiramide en tooling", "Heading 1")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Laag", "Wat wordt getest", "Framework", "Wanneer"],
|
||||||
|
[
|
||||||
|
["Unit", "Pure functies, berekeningslogica, Zod-schema's, hulpfuncties", "Vitest", "Bij elke commit"],
|
||||||
|
["Integratie", "Servicelaag, server actions, Supabase-queries", "Vitest + echte Supabase", "Bij elke commit"],
|
||||||
|
["Database / RLS", "RLS-beleid direct in PostgreSQL", "pgTAP via supabase test db", "Bij elke commit"],
|
||||||
|
["End-to-end", "Volledige gebruikersflows in echte browser", "Playwright", "Bij PR naar main"],
|
||||||
|
["Cybersecurity", "OWASP Top 10, headers, encryptie, IDOR", "ZAP + handmatig", "Vóór elke release"],
|
||||||
|
["Handmatig / validatie", "Usability, toegankelijkheid, copy, regressie", "Checklist", "Vóór elke release"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "8.1 Vitest — unit en integratietests", "Heading 2")
|
||||||
|
p(doc, "npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths", "Normal")
|
||||||
|
p(doc, "npx vitest — watch mode", "Normal")
|
||||||
|
p(doc, "npx vitest run — eenmalig alle tests", "Normal")
|
||||||
|
p(doc, "npx vitest run lib/checkin/__tests__/budget.test.ts — één bestand", "Normal")
|
||||||
|
|
||||||
|
p(doc, "8.2 Playwright — end-to-end tests", "Heading 2")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Authenticatie verloopt programmatisch via de Supabase Auth REST API (global-setup.ts). "
|
||||||
|
"Het token wordt eenmalig opgehaald en hergebruikt als cookie-state voor alle tests.",
|
||||||
|
)
|
||||||
|
p(doc, "npm install -D @playwright/test && npx playwright install", "Normal")
|
||||||
|
p(doc, "npx playwright test — alle E2E-tests", "Normal")
|
||||||
|
p(doc, "npx playwright test --workers=1 — bij Supabase connection-limiet in CI", "Normal")
|
||||||
|
|
||||||
|
p(doc, "8.3 Zod — runtime-validatie en testschema's", "Heading 2")
|
||||||
|
p(doc, "npm install zod && npm install -D zod-fixture", "Normal")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Zod-schema's in lib/*/schemas.ts worden hergebruikt in server actions én client-componenten. "
|
||||||
|
"zod-fixture genereert automatisch testfixtures vanuit het schema.",
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "8.4 pgTAP — RLS en database-tests", "Heading 2")
|
||||||
|
p(doc, "supabase test db — voert alle .sql-testbestanden in supabase/tests/ uit", "Normal")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"pgTAP draait direct in PostgreSQL op hetzelfde uitvoerniveau als productieverkeer. "
|
||||||
|
"Dit is de enige methode die RLS-gedrag betrouwbaar verifieert.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 9. Unit tests ────────────────────────────────────────────────────────
|
||||||
|
p(doc, "9. Unit tests", "Heading 1")
|
||||||
|
|
||||||
|
p(doc, "9.1 Budgetberekening (risicoscore: Kritiek)", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Testgeval", "Input", "Verwacht resultaat"],
|
||||||
|
[
|
||||||
|
["Minimale score", "energyScore = 1", "energyLevel = 'very_low', dailyBudget = minimumwaarde"],
|
||||||
|
["Maximale score", "energyScore = 10", "energyLevel = 'high', dailyBudget = maximumwaarde"],
|
||||||
|
["Elke grenswaarde", "Score op elke overgangswaarde", "Correct niveau en budget"],
|
||||||
|
["Deterministisch", "Zelfde score twee keer", "Altijd identiek resultaat"],
|
||||||
|
["Ongeldige invoer", "energyScore = 0, 11, -1, 'hoog'", "ZodError vóór berekening"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "9.2 Zod-schema's per domein", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Schema", "Locatie", "Te testen gevallen"],
|
||||||
|
[
|
||||||
|
["MorningCheckInSchema", "lib/checkin/schemas.ts", "Geldige check-in, score buiten bereik, te lange notitie"],
|
||||||
|
["OnboardingSubmissionSchema", "lib/onboarding/schemas.ts", "Geldige onboarding, ongeldige tijdzone"],
|
||||||
|
["SettingsSubmissionSchema", "lib/profile/schemas.ts", "Geldige settings, ongeldige herinneringstijd"],
|
||||||
|
["PlannedActivitySchema", "lib/planning/schemas.ts", "Geldige activiteit, negatieve energiepunten"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
p(doc, "9.3 Navigatie-utilities (risicoscore: Hoog)", "Heading 2")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Functie", "Bestand", "Te testen gevallen"],
|
||||||
|
[
|
||||||
|
["sanitizeNextPath()", "lib/auth/navigation.ts", "Geldig pad, dubbele slash (//evil.com), externe URL, leeg pad"],
|
||||||
|
["buildPathWithQuery()", "lib/auth/navigation.ts", "Geen params, meerdere params, speciale tekens"],
|
||||||
|
["getAuthNotice()", "lib/auth/messages.ts", "Bekende foutcode, onbekende code, statuscode"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 10. Integratietests ──────────────────────────────────────────────────
|
||||||
|
p(doc, "10. Integratietests", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Integratietests gebruiken een echte Supabase-testdatabase. Mocks worden niet ingezet voor de databaselaag: "
|
||||||
|
"mock-gedrag verschilt van productiegedrag en verbergt RLS- en queryfouten.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Test", "Doel", "Risicoscore"],
|
||||||
|
[
|
||||||
|
["getProfileBundleForCurrentUser()", "Retourneert gecombineerd profiel en settings", "Hoog"],
|
||||||
|
["ensureProfileBundleForCurrentUser()", "Maakt records aan als ze niet bestaan (bootstrap)", "Hoog"],
|
||||||
|
["createMorningCheckIn() — geldig", "Check-in opgeslagen, budget berekend en teruggegeven", "Kritiek"],
|
||||||
|
["createMorningCheckIn() — ongeldige invoer", "Zod fout vóór databaseschrijf", "Kritiek"],
|
||||||
|
["Rate limit: >10 snelle loginpogingen", "429-respons of vertraging aantoonbaar", "Hoog"],
|
||||||
|
["Reflectie-job idempotentie", "Dubbel uitvoeren geeft geen dubbele prompts", "Middel"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 11. RLS en security tests ────────────────────────────────────────────
|
||||||
|
p(doc, "11. RLS en security tests (pgTAP)", "Heading 1")
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Elke tabel met gebruikersdata krijgt een eigen testbestand. "
|
||||||
|
"Tests worden uitgevoerd als de 'authenticated'-rol met een gesimuleerd JWT. "
|
||||||
|
"De SQL Editor-rol mag nooit worden gebruikt, omdat die RLS omzeilt.",
|
||||||
|
)
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Testgeval", "Te verifiëren"],
|
||||||
|
[
|
||||||
|
["SELECT eigen rij", "Gebruiker A ziet alleen zijn eigen record"],
|
||||||
|
["SELECT andermans rij (IDOR)", "Gebruiker A ziet 0 rijen van gebruiker B"],
|
||||||
|
["INSERT voor zichzelf", "Aanmaken eigen record lukt"],
|
||||||
|
["INSERT voor een ander", "RLS-fout of 0 rows inserted"],
|
||||||
|
["UPDATE eigen rij", "Eigen record aanpassen lukt"],
|
||||||
|
["UPDATE andermans rij", "0 rows updated"],
|
||||||
|
["DELETE eigen rij", "Verwijderen eigen record lukt"],
|
||||||
|
["DELETE andermans rij", "0 rows deleted"],
|
||||||
|
["Unauthenticated (geen JWT)", "0 rijen, geen informatielekking in foutmelding"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 12. E2E tests ────────────────────────────────────────────────────────
|
||||||
|
p(doc, "12. End-to-end tests (Playwright)", "Heading 1")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Flow", "Kritieke assertions", "Risicoscore"],
|
||||||
|
[
|
||||||
|
["Registratie + e-mailbevestiging", "Dashboard bereikbaar na bevestiging", "Hoog"],
|
||||||
|
["Inloggen", "Dashboard zichtbaar, profiel aanwezig", "Hoog"],
|
||||||
|
["Beveiligde route zonder sessie", "Redirect naar /login", "Kritiek"],
|
||||||
|
["IDOR-poging (cross-user)", "Gebruiker B kan data van A niet ophalen", "Kritiek"],
|
||||||
|
["Ochtendcheck-in", "Budget en energieniveau zichtbaar na opslaan", "Kritiek"],
|
||||||
|
["Activiteit plannen + energiemeter", "Meter update direct, activiteit in overzicht", "Hoog"],
|
||||||
|
["Activiteit uitgevoerd / geskipt / aangepast", "Status correct in dagoverzicht", "Middel"],
|
||||||
|
["Instellingen wijzigen", "Succesbericht, instelling persistent na herlaad", "Middel"],
|
||||||
|
["Uitloggen", "Dashboard onbereikbaar na uitloggen", "Hoog"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 13. Testdata-management ──────────────────────────────────────────────
|
||||||
|
p(doc, "13. Testdata-management", "Heading 1")
|
||||||
|
bullets(
|
||||||
|
doc,
|
||||||
|
[
|
||||||
|
"Elke E2E-test maakt eigen testdata aan of hergebruikt een dedicated testaccount. Nooit productiedata.",
|
||||||
|
"Unit- en integratietests zijn stateless: geen gedeelde databaserecords.",
|
||||||
|
"Gebruik zod-fixture om valide testobjecten te genereren vanuit Zod-schema's.",
|
||||||
|
"Na integratietests worden records opgeruimd in afterEach of afterAll.",
|
||||||
|
"Seed-scripts voor statische referentiedata staan in supabase/seed.sql.",
|
||||||
|
"Gebruik een apart Supabase-testproject voor CI.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 14. CI/CD-integratie ─────────────────────────────────────────────────
|
||||||
|
p(doc, "14. CI/CD-integratie", "Heading 1")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Job", "Trigger", "Stappen", "Blokkeerend voor merge"],
|
||||||
|
[
|
||||||
|
["Lint en build", "PR en push naar main", "npm ci, npm run lint, npm run build", "Ja"],
|
||||||
|
["Unit en integratie", "PR en push naar main", "npm ci, npx vitest run, supabase test db", "Ja"],
|
||||||
|
["E2E", "PR naar main", "npm ci, npx playwright install, npx playwright test --workers=1", "Ja"],
|
||||||
|
["Security headers check", "PR naar main", "curl-check op staging-URL", "Ja"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
p(
|
||||||
|
doc,
|
||||||
|
"Mislukte tests blokkeren de merge. Overgeslagen tests vereisen expliciete goedkeuring "
|
||||||
|
"inclusief gedocumenteerde risicoafweging (ISO 13485-principe: non-conformity met CAPA).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 15. Bestandsstructuur ────────────────────────────────────────────────
|
||||||
|
p(doc, "15. Bestandsstructuur", "Heading 1")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Pad", "Inhoud"],
|
||||||
|
[
|
||||||
|
["lib/checkin/__tests__/budget.test.ts", "Unit tests budgetberekening (risico: Kritiek)"],
|
||||||
|
["lib/checkin/schemas.ts", "Zod-schema MorningCheckIn"],
|
||||||
|
["lib/auth/__tests__/navigation.test.ts", "Unit tests open-redirect-preventie (risico: Hoog)"],
|
||||||
|
["lib/profile/__tests__/service.test.ts", "Integratietests profileservice"],
|
||||||
|
["supabase/tests/test_profiles_rls.sql", "pgTAP RLS-tests profiles"],
|
||||||
|
["supabase/tests/test_user_settings_rls.sql", "pgTAP RLS-tests user_settings"],
|
||||||
|
["supabase/tests/test_morning_check_ins_rls.sql", "pgTAP RLS-tests check-ins"],
|
||||||
|
["supabase/tests/test_activities_rls.sql", "pgTAP RLS-tests activiteiten"],
|
||||||
|
["e2e/auth.spec.ts", "Playwright: registratie, login, uitloggen, IDOR-poging"],
|
||||||
|
["e2e/checkin.spec.ts", "Playwright: ochtendcheck-in"],
|
||||||
|
["e2e/planning.spec.ts", "Playwright: activiteiten plannen en evalueren"],
|
||||||
|
["e2e/settings.spec.ts", "Playwright: instellingen"],
|
||||||
|
["playwright/global-setup.ts", "Programmatische Supabase-login, sessiestatus opslaan"],
|
||||||
|
["playwright.config.ts", "Playwright-configuratie incl. auth-setup en workers"],
|
||||||
|
["docs/traceability-matrix.md", "Levend document: FR-ID → test → resultaat per release"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 16. Acceptatiecriteria en Definition of Done ─────────────────────────
|
||||||
|
p(doc, "16. Acceptatiecriteria en Definition of Done", "Heading 1")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Laag", "Minimale eis voor launch"],
|
||||||
|
[
|
||||||
|
["Unit (Kritiek)", "Budgetberekening: 100% dekking op alle grenswaarden en ongeldige invoer."],
|
||||||
|
["Unit (Hoog)", "Zod-schema's getest op geldige en ongeldige invoer. Navigatie-utilities getest op open-redirect."],
|
||||||
|
["Integratie", "Profileservice: happy path en bootstrap. Check-in service: opslaan + budgetoutput. Rate limiting aantoonbaar actief."],
|
||||||
|
["RLS (pgTAP)", "Alle tabellen: SELECT/INSERT/UPDATE/DELETE als owner, als andere gebruiker en zonder sessie getest."],
|
||||||
|
["E2E", "Login, check-in, planning, evaluatie en uitloggen geautomatiseerd. IDOR-poging geeft geen data. Beveiligde route zonder sessie redirect."],
|
||||||
|
["Cybersecurity", "OWASP Top 10 scan zonder kritieke bevindingen. Security headers A-rating. AES-256 data at rest bevestigd."],
|
||||||
|
["Traceability (ISO 13485)", "Alle FR-IDs zijn gekoppeld aan een test. Kritieke FR's hebben gedocumenteerd testresultaat."],
|
||||||
|
["QMS", "Mislukte tests gedocumenteerd als non-conformity in Linear. Interne release-review van traceability matrix uitgevoerd."],
|
||||||
|
["Validatie (handmatig)", "Kernflows geverifieerd op mobiel. Usability test met minimaal 1 echte gebruiker. Copy getoetst op niet-medische formulering."],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 17. Bewuste keuzes ───────────────────────────────────────────────────
|
||||||
|
p(doc, "17. Bewuste keuzes en afwegingen", "Heading 1")
|
||||||
|
table(
|
||||||
|
doc,
|
||||||
|
["Keuze", "Alternatief", "Reden"],
|
||||||
|
[
|
||||||
|
["Echte Supabase in integratietests", "Gemockte client", "Mocks verbergen RLS- en querygedrag. Mock/prod-divergentie is een bewezen risico."],
|
||||||
|
["pgTAP voor RLS", "Applicatielaag-tests", "RLS draait in de database; pgTAP test op hetzelfde uitvoerniveau."],
|
||||||
|
["Risicogebaseerde prioritering (ISO 14971)", "Gelijke dekking per module", "Testcapaciteit wordt ingezet waar de gevolgen van een fout het grootst zijn."],
|
||||||
|
["Traceability matrix (ISO 13485)", "Geen formele koppeling", "Vereiste voor auditeerbare kwaliteitsborging en MDR-voorbereiding."],
|
||||||
|
["NEN 7510 als toetssteen nu al", "Alleen OWASP", "Verlaagt drempel naar medische track, vermindert privacyrisico's bij gezondheidsdata."],
|
||||||
|
["ISO 13485 als leidraad (niet gecertificeerd)", "Geen QMS-structuur", "Bouwt documentregister en non-conformity-aanpak op zonder onnodige overhead."],
|
||||||
|
["Programmatische Playwright-login", "UI-login per test", "Sneller, minder foutgevoelig, scheidt auth-test van feature-test."],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 18. Externe referenties ──────────────────────────────────────────────
|
||||||
|
p(doc, "18. Externe referenties", "Heading 1")
|
||||||
|
references = [
|
||||||
|
("Next.js Testing Guide — Vitest", "https://nextjs.org/docs/app/guides/testing/vitest"),
|
||||||
|
("Next.js Testing Guide — Playwright", "https://nextjs.org/docs/app/guides/testing/playwright"),
|
||||||
|
("Supabase Testing Overview", "https://supabase.com/docs/guides/local-development/testing/overview"),
|
||||||
|
("pgTAP documentatie", "https://pgtap.org/"),
|
||||||
|
("Zod documentatie", "https://zod.dev/"),
|
||||||
|
("zod-fixture — testdata genereren vanuit Zod-schema's", "https://github.com/timdeschryver/zod-fixture"),
|
||||||
|
("Playwright — Supabase auth via REST API", "https://mokkapps.de/blog/login-at-supabase-via-rest-api-in-playwright-e2e-test"),
|
||||||
|
("Playwright — opslaan en hergebruiken van auth-state", "https://playwright.dev/docs/auth"),
|
||||||
|
("ISO 14971 — Risicomanagement voor medische hulpmiddelen", "https://www.iso.org/standard/72704.html"),
|
||||||
|
("ISO 13485 — Kwaliteitsmanagementsystemen voor medische hulpmiddelen", "https://www.iso.org/standard/59752.html"),
|
||||||
|
("NEN 7510 — Informatiebeveiliging in de zorg", "https://www.nen.nl/nen-7510-1-2017-nl-237552"),
|
||||||
|
("OWASP Top 10 — Meest kritieke webapplicatierisico's", "https://owasp.org/www-project-top-ten/"),
|
||||||
|
("OWASP ZAP — Geautomatiseerde securityscanner", "https://www.zaproxy.org/"),
|
||||||
|
("SSL Labs — TLS-configuratiecheck", "https://www.ssllabs.com/ssltest/"),
|
||||||
|
("securityheaders.com — HTTP security headers checker", "https://securityheaders.com/"),
|
||||||
|
("EU MDR 2017/745 — Medical Device Regulation", "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32017R0745"),
|
||||||
|
]
|
||||||
|
for name, url in references:
|
||||||
|
para = doc.add_paragraph(style="List Bullet")
|
||||||
|
para.add_run(f"{name}: ")
|
||||||
|
add_hyperlink(para, url, url)
|
||||||
|
|
||||||
|
set_footer(doc, f"{PRODUCT_NAME} Testplan v0.2")
|
||||||
|
doc.save(BASE_DIR / "inspannings-monitor-07-testplan-v01.docx")
|
||||||
|
print("Testplan v0.2 gegenereerd.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
build_testplan()
|
||||||
111
docs/gpt-instructies.md
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# GPT Instructies voor Inspannings Monitor
|
||||||
|
|
||||||
|
Datum: `2026-04-18`
|
||||||
|
|
||||||
|
Dit document bundelt de inhoudelijke instructies, keuzes en werkafspraken die in
|
||||||
|
deze context door de gebruiker zijn gegeven. Het is bedoeld als compacte
|
||||||
|
contextbron voor vervolgwerk naast de formele specificaties en plannen.
|
||||||
|
|
||||||
|
## 1. Productrichting en positionering
|
||||||
|
|
||||||
|
- Kies bewust de route `wellness/self-management` voor de eerste release.
|
||||||
|
- Houd expliciet de mogelijkheid open om later een apart `medisch product`-spoor te starten.
|
||||||
|
- Volg de eerder aanbevolen guardrails voor intended use en non-intended use.
|
||||||
|
- Houd de MVP weg van medische claims, zorgverlenerrollen en deelscenario's.
|
||||||
|
|
||||||
|
## 2. Naam, doelgroep en taal
|
||||||
|
|
||||||
|
- Productnaam: `Inspannings Monitor`
|
||||||
|
- Doelgroep: `volwassenen`
|
||||||
|
- Voertaal eerste release: `Nederlands`
|
||||||
|
|
||||||
|
## 3. Scope voor release 1
|
||||||
|
|
||||||
|
- Alleen individuele gebruikers
|
||||||
|
- Geen delen met zorgverleners
|
||||||
|
- Geen delen met naasten
|
||||||
|
- Geen AI in de kern-MVP
|
||||||
|
- Geen medische workflows in de MVP
|
||||||
|
|
||||||
|
## 4. Technische keuzes
|
||||||
|
|
||||||
|
- Hosting: `Vercel`
|
||||||
|
- Database: `Supabase PostgreSQL`
|
||||||
|
- Authenticatie: `Supabase Auth`
|
||||||
|
- UI foundation: `Tailwind CSS + shadcn/ui`
|
||||||
|
|
||||||
|
## 5. Documentatie-instructies
|
||||||
|
|
||||||
|
- Maak nieuwe documentatie voor de gekozen wellness-route.
|
||||||
|
- Neem de technische implementatielaag uit `v04` mee als aparte laag, niet vermengd met productscope.
|
||||||
|
- Bouw de documentatieset op als losse, duidelijke artefacten in plaats van één gemengd document.
|
||||||
|
- Houd documentatie beschikbaar in `.docx`, met ondersteunende Markdown-bestanden in de repository.
|
||||||
|
|
||||||
|
## 6. Backlog en projectsturing
|
||||||
|
|
||||||
|
- Gebruik `Linear` als backlogtool.
|
||||||
|
- Werk de documentatie door naar backlog- en importbestanden voor Linear.
|
||||||
|
- Gebruik de storystructuur (`ST-001`, `ST-101`, `ST-102`, enzovoort) als uitvoeringslijn.
|
||||||
|
|
||||||
|
## 7. Implementatiekeuzes die expliciet zijn gevraagd
|
||||||
|
|
||||||
|
- Bouw door vanaf `ST-001` met echte code, niet alleen plannen.
|
||||||
|
- Voeg `Supabase Auth` toe met e-mail/wachtwoord en verplichte verificatie.
|
||||||
|
- Bouw daarna profiel- en settingsfundering, onboarding en settingsbeheer.
|
||||||
|
- Verbeter de UI structureel door `shadcn/ui` te gebruiken in plaats van losse knop- en form-styling.
|
||||||
|
|
||||||
|
## 8. Repository- en deploykeuzes
|
||||||
|
|
||||||
|
- Publiceer het project op GitHub.
|
||||||
|
- Gebruik repositorynaam `inspannings-monitor`.
|
||||||
|
- Maak de repository `public`.
|
||||||
|
- Gebruik voor productie niet de root `jp-visser.nl`, omdat daar al de hoofdsite met cv en projectlinks staat.
|
||||||
|
- Gebruik als productiedomein: `inspannings-monitor.jp-visser.nl`
|
||||||
|
|
||||||
|
## 9. CI/CD-afspraken
|
||||||
|
|
||||||
|
- Gebruik `GitHub Actions` voor CI.
|
||||||
|
- Gebruik `Vercel` voor automatische preview- en production-deployments.
|
||||||
|
- Gebruik `main` als production branch.
|
||||||
|
- Bescherm `main` met:
|
||||||
|
- pull requests verplicht
|
||||||
|
- verplichte check `Lint and build`
|
||||||
|
- force pushes geblokkeerd
|
||||||
|
- branch deletion geblokkeerd
|
||||||
|
|
||||||
|
## 10. Security-afspraken
|
||||||
|
|
||||||
|
- Gebruik geen `service_role` key in de frontend-app.
|
||||||
|
- Gebruik geen admin-key in Vercel voor deze frontend.
|
||||||
|
- Behandel de eerder gebruikte Supabase `service_role` key als gecompromitteerd.
|
||||||
|
- Houd lokale env-bestanden buiten git.
|
||||||
|
|
||||||
|
## 11. Werkvoorkeuren uit deze context
|
||||||
|
|
||||||
|
- Ga praktisch door met de volgende stap als de richting duidelijk is.
|
||||||
|
- Maak documentatie en implementatie samen voortschrijdend concreet.
|
||||||
|
- Leg belangrijke keuzes expliciet vast wanneer ze eenmaal zijn besloten.
|
||||||
|
- Geef voor gebruikersfeedback na redirects of server actions de voorkeur aan een
|
||||||
|
centrale toastlaag boven losse inline statusnotices, tenzij een scherm expliciet
|
||||||
|
een andere vorm vraagt.
|
||||||
|
|
||||||
|
## 12. Korte besluitlog uit deze thread
|
||||||
|
|
||||||
|
1. Twee oorspronkelijke documenten zijn beoordeeld en omgezet naar een nieuwe documentatieset.
|
||||||
|
2. De wellness-route is expliciet gekozen met opengehouden future-medical track.
|
||||||
|
3. Productnaam is vastgezet op `Inspannings Monitor`.
|
||||||
|
4. Release 1 is vastgezet op individuele volwassen gebruikers in het Nederlands.
|
||||||
|
5. De stack is vastgezet op `Vercel + Supabase Auth + Supabase PostgreSQL`.
|
||||||
|
6. De technische implementatielaag uit `v04` is teruggebracht als apart document.
|
||||||
|
7. De backlog is uitgewerkt en voorbereid voor `Linear`.
|
||||||
|
8. De app is opgebouwd via de stories `ST-001`, `ST-101`, `ST-102`, `ST-103` en `ST-104`.
|
||||||
|
9. De UI is later structureel gemigreerd naar `shadcn/ui`.
|
||||||
|
10. De repository is publiek gemaakt, gekoppeld aan Vercel en op `inspannings-monitor.jp-visser.nl` gezet.
|
||||||
|
11. CI/CD en branch protection zijn ingericht rond `main` en `Lint and build`.
|
||||||
|
|
||||||
|
## 13. Gerelateerde documenten
|
||||||
|
|
||||||
|
- [docs/README.md](/Users/janpetervisser/Development/third/docs/README.md)
|
||||||
|
- [inspannings-monitor-cicd-en-deploy.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-cicd-en-deploy.md)
|
||||||
|
- [inspannings-monitor-dagelijkse-deploy-checklist.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-dagelijkse-deploy-checklist.md)
|
||||||
|
- [inspannings-monitor-ops-security-notitie.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-ops-security-notitie.md)
|
||||||
29
docs/icon-concepts/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Icon-concepten voor Inspannings Monitor
|
||||||
|
|
||||||
|
Deze map bevat drie eerste SVG-concepten voor de gekozen richting
|
||||||
|
`rustige energiering`.
|
||||||
|
|
||||||
|
## Gekozen richting
|
||||||
|
|
||||||
|
`Concept 03` is gekozen als basis voor de app. De uitgewerkte master staat nu
|
||||||
|
in [app/icon.svg](/Users/janpetervisser/Development/third/app/icon.svg).
|
||||||
|
|
||||||
|
## Concepten
|
||||||
|
|
||||||
|
- `concept-01-open-ring.svg`
|
||||||
|
Een heldere open ring met een zacht accentpunt. Dit is de meest directe en
|
||||||
|
rustige variant.
|
||||||
|
|
||||||
|
- `concept-02-double-orbit.svg`
|
||||||
|
Een open ring met een tweede lichte baan. Dit voelt iets dynamischer en meer
|
||||||
|
als pacing of beweging in lagen.
|
||||||
|
|
||||||
|
- `concept-03-horizon-ring.svg`
|
||||||
|
Een open ring met een zachte binnenboog. Dit legt meer nadruk op ritme en een
|
||||||
|
kalme daglijn.
|
||||||
|
|
||||||
|
## Opmerking
|
||||||
|
|
||||||
|
Dit zijn nog geen definitieve app-icon exports, maar SVG-masters op
|
||||||
|
`512x512` formaat. Vanuit de gekozen richting kunnen later `favicon`,
|
||||||
|
`apple-icon` en `app icon` varianten worden geëxporteerd.
|
||||||
16
docs/icon-concepts/concept-01-open-ring.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="24" y="24" width="464" height="464" rx="112" fill="#F4EFE5"/>
|
||||||
|
<circle
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="142"
|
||||||
|
fill="none"
|
||||||
|
stroke="#1F5C49"
|
||||||
|
stroke-width="60"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="710 190"
|
||||||
|
transform="rotate(-42 256 256)"
|
||||||
|
/>
|
||||||
|
<circle cx="372" cy="149" r="28" fill="#A7C957"/>
|
||||||
|
<circle cx="256" cy="256" r="30" fill="#1F5C49" fill-opacity="0.12"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 510 B |
27
docs/icon-concepts/concept-02-double-orbit.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="24" y="24" width="464" height="464" rx="112" fill="#F4EFE5"/>
|
||||||
|
<circle
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="136"
|
||||||
|
fill="none"
|
||||||
|
stroke="#234B3F"
|
||||||
|
stroke-width="54"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="600 180"
|
||||||
|
transform="rotate(-48 256 256)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="178"
|
||||||
|
fill="none"
|
||||||
|
stroke="#C9D9B3"
|
||||||
|
stroke-width="18"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="310 820"
|
||||||
|
transform="rotate(20 256 256)"
|
||||||
|
/>
|
||||||
|
<circle cx="351" cy="162" r="24" fill="#B7CF6B"/>
|
||||||
|
<circle cx="256" cy="256" r="18" fill="#234B3F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 695 B |
27
docs/icon-concepts/concept-03-horizon-ring.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="24" y="24" width="464" height="464" rx="112" fill="#F4EFE5"/>
|
||||||
|
<circle
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="144"
|
||||||
|
fill="none"
|
||||||
|
stroke="#20493B"
|
||||||
|
stroke-width="58"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="660 210"
|
||||||
|
transform="rotate(-34 256 256)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M162 292C188 272 221 262 256 262C291 262 324 272 350 292"
|
||||||
|
stroke="#D8C3A5"
|
||||||
|
stroke-width="28"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M188 318C208 302 232 294 256 294C280 294 304 302 324 318"
|
||||||
|
stroke="#B7CF6B"
|
||||||
|
stroke-width="16"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="364" cy="169" r="24" fill="#B7CF6B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 734 B |
BIN
docs/inspannings-monitor-07-testplan-v01.docx
Normal file
BIN
docs/inspannings-monitor-08-gebruikerspersonas-v01.docx
Normal file
620
docs/inspannings-monitor-09-dusk-theme-specificatie-v01.md
Normal file
|
|
@ -0,0 +1,620 @@
|
||||||
|
# Inspannings Monitor — 09 · Dusk Theme Specificatie
|
||||||
|
|
||||||
|
**Versie:** v0.1 · **Datum:** 2026-04-19 · **Status:** Normatief, basis nu in code doorgevoerd
|
||||||
|
**Scope:** UI-thema voor Next.js 16 + shadcn/ui + Tailwind v4 codebase
|
||||||
|
**Variant:** Dusk · **Dark mode:** Prioriteit · **Icon-set:** Lucide
|
||||||
|
|
||||||
|
Bij conflict met eerdere documenten wint deze specificatie voor alles wat
|
||||||
|
visueel design betreft. Functioneel gedrag blijft geregeld in
|
||||||
|
`02-functionele-specificatie-mvp`.
|
||||||
|
|
||||||
|
## 0 · Implementatiestatus
|
||||||
|
|
||||||
|
Per 2026-04-19 is de basis van dit thema in de app doorgevoerd:
|
||||||
|
|
||||||
|
- centrale `oklch()`-tokens in `app/globals.css`
|
||||||
|
- `Inter Tight` + `IBM Plex Mono` via `next/font`
|
||||||
|
- dark mode als standaard via `next-themes`
|
||||||
|
- semantische componentvarianten voor cards, alerts en buttons
|
||||||
|
- Dusk-shell op landing, dashboard, auth, onboarding, check-in, planning en settings
|
||||||
|
- accessibility-polish voor focus, keuzegroepen en de EnergyMeter-progressbar
|
||||||
|
|
||||||
|
Nog bewust iteratief:
|
||||||
|
|
||||||
|
- fijnslijpen van iconmapping per view
|
||||||
|
- laatste micro-spacing en copy-patronen in nieuwe features
|
||||||
|
- eventuele theme toggle als productkeuze voor later
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Rationale
|
||||||
|
|
||||||
|
Inspannings Monitor richt zich op volwassenen die rust zoeken in het
|
||||||
|
plannen van hun energie. Dusk is gekozen omdat:
|
||||||
|
|
||||||
|
- **Warme papertone** (ivory, L 97%) vermindert klinische feel — de app
|
||||||
|
is expliciet *wellness*, geen medisch hulpmiddel.
|
||||||
|
- **Gedempte indigo primary** (hue 262, chroma 0.11) voelt kalm en
|
||||||
|
"avondlicht" aan. Past bij de dagelijkse lus (ochtendcheck-in →
|
||||||
|
dagplanning → avondreflectie) die doorloopt tot de avond.
|
||||||
|
- **Whisper-quiet contrast** (nooit echt zwart-op-wit) voorkomt
|
||||||
|
schermmoeheid en matcht de rustige copywriting.
|
||||||
|
- **Dark mode als spiegel** in dezelfde hue, iets hogere chroma op ink —
|
||||||
|
identiteit blijft behouden zonder saai navy te worden.
|
||||||
|
|
||||||
|
### Niet-doelen
|
||||||
|
|
||||||
|
- Geen medische signalen (geen rood-groen triage-semantiek).
|
||||||
|
- Geen "motivational" UI (geen grote cijfers, geen gamification-kleur).
|
||||||
|
- Geen hoge-energie accenten (chroma > 0.13 uitgesloten).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · Kleurpalet
|
||||||
|
|
||||||
|
Alle kleuren in **oklch()**. Namen volgen shadcn/ui-conventies zodat alle
|
||||||
|
bestaande componenten zonder aanpassing werken.
|
||||||
|
|
||||||
|
### 2.1 · Light mode
|
||||||
|
|
||||||
|
| Token | Waarde | Gebruik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--background` | `oklch(97% 0.008 80)` | Paper |
|
||||||
|
| `--muted` | `oklch(95% 0.012 82)` | Paper-soft (rail, rustvlak) |
|
||||||
|
| `--card` | `oklch(99% 0.004 80)` | Kaartoppervlak |
|
||||||
|
| `--popover` | `oklch(100% 0 0)` | Popovers, toasts, sheets |
|
||||||
|
| `--foreground` | `oklch(22% 0.03 262)` | Primaire tekst (contrast 14.2:1) |
|
||||||
|
| `--muted-foreground` | `oklch(58% 0.015 262)` | Secundaire tekst (AA 4.5:1) |
|
||||||
|
| `--primary` | `oklch(44% 0.11 262)` | Muted indigo |
|
||||||
|
| `--primary-foreground` | `oklch(98% 0.01 262)` | Tekst op primary |
|
||||||
|
| `--secondary` / `--accent` | `oklch(92% 0.03 262)` | Primary-soft vlak |
|
||||||
|
| `--border` | `oklch(22% 0.03 262 / 0.10)` | Hairline |
|
||||||
|
| `--input` | `oklch(22% 0.03 262 / 0.12)` | Form border |
|
||||||
|
| `--ring` | `oklch(44% 0.11 262)` | Focus |
|
||||||
|
| `--destructive` | `oklch(58% 0.16 25)` | Alleen echte destructieve acties |
|
||||||
|
| `--success` | `oklch(62% 0.09 155)` | Succes, voltooid |
|
||||||
|
| `--warning` | `oklch(72% 0.10 70)` | Budget-overschrijding |
|
||||||
|
|
||||||
|
### 2.2 · Dark mode
|
||||||
|
|
||||||
|
| Token | Waarde |
|
||||||
|
| --- | --- |
|
||||||
|
| `--background` | `oklch(17% 0.02 262)` |
|
||||||
|
| `--card` | `oklch(22% 0.025 262)` |
|
||||||
|
| `--popover` | `oklch(22% 0.025 262)` |
|
||||||
|
| `--foreground` | `oklch(96% 0.008 80)` |
|
||||||
|
| `--muted` | `oklch(26% 0.025 262)` |
|
||||||
|
| `--muted-foreground` | `oklch(70% 0.015 262)` |
|
||||||
|
| `--primary` | `oklch(78% 0.08 262)` |
|
||||||
|
| `--primary-foreground` | `oklch(20% 0.03 262)` |
|
||||||
|
| `--secondary` | `oklch(28% 0.03 262)` |
|
||||||
|
| `--accent` | `oklch(30% 0.04 262)` |
|
||||||
|
| `--border` | `oklch(100% 0 0 / 0.10)` |
|
||||||
|
| `--ring` | `oklch(78% 0.08 262)` |
|
||||||
|
| `--destructive` | `oklch(70% 0.16 25)` |
|
||||||
|
| `--success` | `oklch(74% 0.09 155)` |
|
||||||
|
| `--warning` | `oklch(80% 0.10 70)` |
|
||||||
|
|
||||||
|
### 2.3 · Charts
|
||||||
|
|
||||||
|
| Token | Waarde | Gebruik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--chart-1` | `oklch(44% 0.11 262)` | Vandaag / primair |
|
||||||
|
| `--chart-2` | `oklch(60% 0.09 262)` | Gemiddelde / trend |
|
||||||
|
| `--chart-3` | `oklch(70% 0.10 50)` | Accent amber — alleen voor overschrijding |
|
||||||
|
| `--chart-4` | `oklch(62% 0.09 155)` | Succestint |
|
||||||
|
| `--chart-5` | `oklch(80% 0.03 262)` | Overige dagen / achtergrond |
|
||||||
|
|
||||||
|
> **Regel:** rood (`--destructive`) nooit gebruiken voor budget — dat is
|
||||||
|
> `--warning` (amber). Rood is gereserveerd voor "verwijder account",
|
||||||
|
> "annuleer abonnement" en vergelijkbaar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 · Typografie
|
||||||
|
|
||||||
|
Eén UI-font: **Inter Tight**. Monospace voor cijfers en timestamps in
|
||||||
|
ondersteunende posities: **IBM Plex Mono**. De huidige display-serif
|
||||||
|
stack wordt verwijderd.
|
||||||
|
|
||||||
|
### 3.1 · Schaal
|
||||||
|
|
||||||
|
| Rol | Grootte | Gewicht | Letterspacing | Line-height |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| H1 | 42 | 500 | -0.03em | 1.08 |
|
||||||
|
| H2 | 22 | 600 | -0.02em | 1.2 |
|
||||||
|
| H3 | 15 | 600 | 0 | 1.3 |
|
||||||
|
| Body | 15 | 400 | 0 | 1.7 |
|
||||||
|
| Micro | 12 | 400 | 0 | 1.5 |
|
||||||
|
| Eyebrow | 11 | 600 | 0.16em uppercase | 1.4 |
|
||||||
|
| Number | variabel | 500 | -0.02em | 1.2 |
|
||||||
|
|
||||||
|
### 3.2 · Regels
|
||||||
|
|
||||||
|
- Alle cijfers in UI krijgen `font-variant-numeric: tabular-nums`. Dit
|
||||||
|
staat globaal op `body` en wordt niet ongedaan gemaakt.
|
||||||
|
- Letterspacing op headings negatief (-0.02 / -0.03) — geen condensed
|
||||||
|
gevoel.
|
||||||
|
- Body line-height 1.65–1.7.
|
||||||
|
- Geen font-weight onder 400 — zwakke tekst is onleesbaar op low-contrast
|
||||||
|
paper.
|
||||||
|
- Geen serif in UI meer. Serif eventueel alleen in gegenereerde PDF's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 · Iconografie — Lucide
|
||||||
|
|
||||||
|
Set: **lucide-react** (al in shadcn-stack). Alle iconen met
|
||||||
|
`strokeWidth={1.5}`, `strokeLinecap="round"`, `strokeLinejoin="round"`.
|
||||||
|
Default grootte **18px** in UI, **20px** in cards, **16px** inline. Kleur
|
||||||
|
erft via `currentColor` — nooit hardcoden.
|
||||||
|
|
||||||
|
### 4.1 · Voorgeschreven mapping
|
||||||
|
|
||||||
|
| Icoon | Doel |
|
||||||
|
| --- | --- |
|
||||||
|
| `Sun` | Ochtendcheck-in |
|
||||||
|
| `Moon` | Avondreflectie |
|
||||||
|
| `Zap` | Energie / budget |
|
||||||
|
| `Calendar` | Dagplanning |
|
||||||
|
| `Clock` | Tijdstip / duur |
|
||||||
|
| `Heart` | Welzijn / herstel |
|
||||||
|
| `Pencil` | Notitie bewerken |
|
||||||
|
| `Settings` | Instellingen |
|
||||||
|
| `Plus` | Activiteit toevoegen |
|
||||||
|
| `Check` | Klaar / voltooid |
|
||||||
|
| `AlertCircle` | Buiten budget (warning) |
|
||||||
|
| `LogOut` | Uitloggen |
|
||||||
|
|
||||||
|
### 4.2 · Regels
|
||||||
|
|
||||||
|
- Nooit twee verschillende icoon-sets mixen in één view.
|
||||||
|
- Rood (`text-destructive`) alleen op echte destructieve acties, nooit
|
||||||
|
voor "over budget" — daar `text-[--warning]` (amber) gebruiken.
|
||||||
|
- Iconen zijn stil: geen hover-animatie, geen kleurwissel behalve de
|
||||||
|
natuurlijke `hover:text-primary` van het parent-element.
|
||||||
|
- Iconen met `aria-hidden` tenzij ze de enige labeldrager zijn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 · Radius & elevatie
|
||||||
|
|
||||||
|
| Token | Waarde | Gebruik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--radius` | `14px` | Kaarten, inputs (basis) |
|
||||||
|
| `--radius-sm` | `8px` | Chips, dense items |
|
||||||
|
| `--radius-lg` | `21px` | Hero cards, dialogs |
|
||||||
|
| `--radius-xl` | `25px` | Sheet / drawer |
|
||||||
|
| `--radius-full` | `9999px` | Buttons (pill), avatars |
|
||||||
|
| `--shadow-1` | 1px + 3px drop | Flat kaarten |
|
||||||
|
| `--shadow-2` | 4/16px drop | Hover, verhoogde kaarten |
|
||||||
|
| `--shadow-3` | 24/60px drop | Popovers, toasts, modals |
|
||||||
|
|
||||||
|
Alle schaduwen zijn indigo-getint (niet neutraal grijs) zodat ze
|
||||||
|
harmoniseren met paper en ink.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 · Motion
|
||||||
|
|
||||||
|
- Standaard transitieduur **160 ms**, easing
|
||||||
|
`cubic-bezier(.2, .7, .2, 1)`.
|
||||||
|
- Meter- en ring-animaties: **400 ms** enter, geen loop.
|
||||||
|
- Modals / popovers: **180 ms** fade + 6 px translate-Y.
|
||||||
|
- `prefers-reduced-motion: reduce` schakelt alles naar **0.01 ms**.
|
||||||
|
- Geen parallax, geen scroll-linked animaties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 · Toegankelijkheid
|
||||||
|
|
||||||
|
- Body-tekst minimaal WCAG AA (4.5:1). Primary-knop AAA in beide modes.
|
||||||
|
- Focus-ring: `2px solid var(--ring)` met `outline-offset: 2px` —
|
||||||
|
**nooit** `outline: none` zonder vervanging.
|
||||||
|
- Hit-targets minimaal 40×40 desktop, **44×44 op touch**.
|
||||||
|
- Scale-buttons (1–10 in check-in): `aria-label="Energie {n} van 10"` +
|
||||||
|
`aria-pressed`.
|
||||||
|
- Alle getalwaarden krijgen een leesbare `aria-label`
|
||||||
|
(bijv. "8 komma 1 punten van 14").
|
||||||
|
- Kleur is nooit de enige signaaldrager — "binnen budget" krijgt altijd
|
||||||
|
ook tekst of een icoon.
|
||||||
|
- Nederlands is de primaire taal. Screen-reader-output moet in het
|
||||||
|
Nederlands kloppen (decimale komma, niet punt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8 · Implementatie
|
||||||
|
|
||||||
|
### 8.1 · `app/globals.css`
|
||||||
|
|
||||||
|
Vervang de inhoud van `:root` en voeg `.dark` toe. Namen blijven
|
||||||
|
shadcn-compatibel; alleen waarden wijzigen.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Type */
|
||||||
|
--font-body: var(--font-inter-tight), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-display: var(--font-body);
|
||||||
|
--font-mono: var(--font-plex-mono), ui-monospace, monospace;
|
||||||
|
|
||||||
|
/* Surfaces */
|
||||||
|
--background: oklch(97% 0.008 80);
|
||||||
|
--foreground: oklch(22% 0.03 262);
|
||||||
|
--card: oklch(99% 0.004 80);
|
||||||
|
--card-foreground: oklch(22% 0.03 262);
|
||||||
|
--popover: oklch(100% 0 0);
|
||||||
|
--popover-foreground: oklch(22% 0.03 262);
|
||||||
|
|
||||||
|
/* Primary — muted indigo */
|
||||||
|
--primary: oklch(44% 0.11 262);
|
||||||
|
--primary-foreground: oklch(98% 0.01 262);
|
||||||
|
--secondary: oklch(92% 0.03 262);
|
||||||
|
--secondary-foreground: oklch(44% 0.11 262);
|
||||||
|
|
||||||
|
/* Muted & accent */
|
||||||
|
--muted: oklch(95% 0.012 82);
|
||||||
|
--muted-foreground: oklch(58% 0.015 262);
|
||||||
|
--accent: oklch(92% 0.03 262);
|
||||||
|
--accent-foreground: oklch(44% 0.11 262);
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--destructive: oklch(58% 0.16 25);
|
||||||
|
--success: oklch(62% 0.09 155);
|
||||||
|
--warning: oklch(72% 0.10 70);
|
||||||
|
|
||||||
|
/* Lijnen & focus */
|
||||||
|
--border: oklch(22% 0.03 262 / 0.10);
|
||||||
|
--input: oklch(22% 0.03 262 / 0.12);
|
||||||
|
--ring: oklch(44% 0.11 262);
|
||||||
|
|
||||||
|
/* Charts */
|
||||||
|
--chart-1: oklch(44% 0.11 262);
|
||||||
|
--chart-2: oklch(60% 0.09 262);
|
||||||
|
--chart-3: oklch(70% 0.10 50);
|
||||||
|
--chart-4: oklch(62% 0.09 155);
|
||||||
|
--chart-5: oklch(80% 0.03 262);
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--sidebar: oklch(95% 0.012 82);
|
||||||
|
--sidebar-foreground: oklch(22% 0.03 262);
|
||||||
|
--sidebar-primary: oklch(44% 0.11 262);
|
||||||
|
--sidebar-primary-foreground: oklch(98% 0.01 262);
|
||||||
|
--sidebar-accent: oklch(92% 0.03 262);
|
||||||
|
--sidebar-accent-foreground: oklch(44% 0.11 262);
|
||||||
|
--sidebar-border: oklch(22% 0.03 262 / 0.08);
|
||||||
|
--sidebar-ring: oklch(44% 0.11 262);
|
||||||
|
|
||||||
|
/* Radius & elevatie */
|
||||||
|
--radius: 14px;
|
||||||
|
--shadow-1: 0 1px 2px oklch(25% 0.03 262 / 0.06), 0 1px 3px oklch(25% 0.03 262 / 0.04);
|
||||||
|
--shadow-2: 0 4px 16px oklch(25% 0.03 262 / 0.08), 0 1px 2px oklch(25% 0.03 262 / 0.04);
|
||||||
|
--shadow-3: 0 24px 60px oklch(25% 0.03 262 / 0.10), 0 2px 6px oklch(25% 0.03 262 / 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(17% 0.02 262);
|
||||||
|
--foreground: oklch(96% 0.008 80);
|
||||||
|
--card: oklch(22% 0.025 262);
|
||||||
|
--card-foreground: oklch(96% 0.008 80);
|
||||||
|
--popover: oklch(22% 0.025 262);
|
||||||
|
--popover-foreground: oklch(96% 0.008 80);
|
||||||
|
|
||||||
|
--primary: oklch(78% 0.08 262);
|
||||||
|
--primary-foreground: oklch(20% 0.03 262);
|
||||||
|
--secondary: oklch(28% 0.03 262);
|
||||||
|
--secondary-foreground: oklch(92% 0.02 262);
|
||||||
|
|
||||||
|
--muted: oklch(26% 0.025 262);
|
||||||
|
--muted-foreground: oklch(70% 0.015 262);
|
||||||
|
--accent: oklch(30% 0.04 262);
|
||||||
|
--accent-foreground: oklch(92% 0.02 262);
|
||||||
|
|
||||||
|
--destructive: oklch(70% 0.16 25);
|
||||||
|
--success: oklch(74% 0.09 155);
|
||||||
|
--warning: oklch(80% 0.10 70);
|
||||||
|
|
||||||
|
--border: oklch(100% 0 0 / 0.10);
|
||||||
|
--input: oklch(100% 0 0 / 0.12);
|
||||||
|
--ring: oklch(78% 0.08 262);
|
||||||
|
|
||||||
|
--chart-1: oklch(78% 0.08 262);
|
||||||
|
--chart-2: oklch(60% 0.09 262);
|
||||||
|
--chart-3: oklch(74% 0.10 50);
|
||||||
|
--chart-4: oklch(74% 0.09 155);
|
||||||
|
--chart-5: oklch(45% 0.03 262);
|
||||||
|
|
||||||
|
--sidebar: oklch(20% 0.025 262);
|
||||||
|
--sidebar-foreground: oklch(96% 0.008 80);
|
||||||
|
--sidebar-primary: oklch(78% 0.08 262);
|
||||||
|
--sidebar-primary-foreground: oklch(20% 0.03 262);
|
||||||
|
--sidebar-accent: oklch(28% 0.03 262);
|
||||||
|
--sidebar-accent-foreground: oklch(92% 0.02 262);
|
||||||
|
--sidebar-border: oklch(100% 0 0 / 0.08);
|
||||||
|
--sidebar-ring: oklch(78% 0.08 262);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: var(--font-body);
|
||||||
|
--font-mono: var(--font-mono);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* { @apply border-border outline-ring/50; }
|
||||||
|
html {
|
||||||
|
color-scheme: light dark;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
button:not(:disabled), [role="button"]:not(:disabled) { cursor: pointer; }
|
||||||
|
|
||||||
|
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 · `app/layout.tsx` — fonts via `next/font`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Inter_Tight, IBM_Plex_Mono } from "next/font/google";
|
||||||
|
|
||||||
|
const interTight = Inter_Tight({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-inter-tight",
|
||||||
|
display: "swap",
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const plexMono = IBM_Plex_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-plex-mono",
|
||||||
|
display: "swap",
|
||||||
|
weight: ["400", "500"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="nl"
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={`${interTight.variable} ${plexMono.variable}`}
|
||||||
|
>
|
||||||
|
<body>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verwijder de bestaande `--font-display` (serif) declaratie en alle
|
||||||
|
`font-[family-name:var(--font-display)]` classes uit views.
|
||||||
|
|
||||||
|
### 8.3 · Dark mode toggle via `next-themes`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i next-themes
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/theme-provider.tsx
|
||||||
|
"use client";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 · `components/ui/theme-toggle.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={isDark ? "Schakel naar licht" : "Schakel naar donker"}
|
||||||
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
|
>
|
||||||
|
{isDark
|
||||||
|
? <Sun className="size-[18px]" strokeWidth={1.5} />
|
||||||
|
: <Moon className="size-[18px]" strokeWidth={1.5} />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 · Class-cleanup tabel
|
||||||
|
|
||||||
|
Hardcoded Tailwind-kleuren ondermijnen het thema. Per view zoek-en-vervang:
|
||||||
|
|
||||||
|
| Oud | Nieuw |
|
||||||
|
| --- | --- |
|
||||||
|
| `bg-white/75 backdrop-blur` | `bg-card` |
|
||||||
|
| `bg-white` | `bg-card` of `bg-popover` |
|
||||||
|
| `text-slate-900` | `text-foreground` |
|
||||||
|
| `text-slate-700` / `text-slate-600` / `text-slate-500` | `text-muted-foreground` |
|
||||||
|
| `border-black/10` | `border-border` |
|
||||||
|
| `rounded-[2rem]` / `rounded-[1.75rem]` | `rounded-xl` (= 1.4× `--radius`) |
|
||||||
|
| `shadow-[0_18px_60px_...]` | `shadow-[var(--shadow-3)]` |
|
||||||
|
| `shadow-[0_12px_40px_...]` | `shadow-[var(--shadow-2)]` |
|
||||||
|
| `bg-[radial-gradient(...)]` hero | verwijderen — paper is al warm |
|
||||||
|
| `font-[family-name:var(--font-display)]` | verwijderen — één font |
|
||||||
|
| `bg-amber-50 text-amber-950` (budget warning) | `bg-muted text-foreground` + `border-[color:var(--warning)]` |
|
||||||
|
|
||||||
|
### 8.6 · Voorbeeld-diff — `app/dashboard/page.tsx`
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- <main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
|
||||||
|
+ <main className="min-h-screen bg-background px-6 py-10 text-foreground sm:px-8">
|
||||||
|
|
||||||
|
- <header className="... rounded-[2rem] border border-black/10 bg-white/75 ... shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur ...">
|
||||||
|
+ <header className="... rounded-xl border border-border bg-card shadow-[var(--shadow-2)] ...">
|
||||||
|
|
||||||
|
- <CardTitle className="text-lg text-slate-900">Cookie-based sessie actief</CardTitle>
|
||||||
|
+ <CardTitle className="text-lg text-foreground">Cookie-based sessie actief</CardTitle>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.7 · Iconen in componenten
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Sun, Calendar, Zap, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
// CheckInCard header:
|
||||||
|
<Sun className="size-[18px] text-primary" strokeWidth={1.5} aria-hidden />
|
||||||
|
|
||||||
|
// PlanningPage primaire actie:
|
||||||
|
<Button>
|
||||||
|
<Plus className="size-4" strokeWidth={1.5} aria-hidden />
|
||||||
|
Activiteit
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9 · Chart-specificatie
|
||||||
|
|
||||||
|
De week-grafiek op het dashboard gebruikt `--chart-1` voor "vandaag" en
|
||||||
|
`--chart-5` voor overige dagen. `--chart-3` (amber) markeert
|
||||||
|
overschrijdingen. **Nooit** `--destructive` (rood) voor budget — dat is
|
||||||
|
`--warning`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10 · Rollout-plan
|
||||||
|
|
||||||
|
| PR | Scope | Omvang |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `globals.css` vervangen (§8.1) | klein |
|
||||||
|
| 2 | Fonts via `next/font` + cleanup `--font-display` (§8.2) | klein |
|
||||||
|
| 3 | Dark mode — `next-themes`, `ThemeToggle` in dashboard + settings (§8.3, §8.4) | middel |
|
||||||
|
| 4 | View cleanup — één commit per route: landing, dashboard, check-in, planning, settings, onboarding (§8.5, §8.6) | groot |
|
||||||
|
| 5 | Lucide-standaardisatie — alle iconen 1.5 stroke, `size-[18px]` default (§4) | klein |
|
||||||
|
| 6 | `/styleguide` route achter `NEXT_PUBLIC_ENABLE_STYLEGUIDE` vlag | klein |
|
||||||
|
|
||||||
|
Richtlijn: maximaal één PR per dag in productie — de grote view-cleanup
|
||||||
|
splitsen over meerdere dagen zodat visual regression per route
|
||||||
|
beoordeeld kan worden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11 · Acceptatiecriteria
|
||||||
|
|
||||||
|
- [ ] Landing, dashboard, ochtendcheck-in, dagplanning, instellingen en
|
||||||
|
onboarding renderen correct in zowel light als dark.
|
||||||
|
- [ ] Er zijn geen hardcoded `text-slate-*`, `bg-white*`, `border-black/*`
|
||||||
|
of hex-kleurwaarden meer in `app/**/*.tsx` of `components/**/*.tsx`.
|
||||||
|
- [ ] Theme-toggle werkt zonder flash; SSR levert de juiste kleur bij
|
||||||
|
eerste render (dankzij `suppressHydrationWarning` + `next-themes`).
|
||||||
|
- [ ] Lighthouse a11y-score ≥ 95 op dashboard in beide modes.
|
||||||
|
- [ ] `prefers-reduced-motion` getest — meter-animaties zijn uit.
|
||||||
|
- [ ] Alle pt-waarden (check-in, planning, dagbudget) gebruiken
|
||||||
|
`tabular-nums`.
|
||||||
|
- [ ] Focus-ring zichtbaar op alle tab-reachable elementen.
|
||||||
|
- [ ] `docs/inspannings-monitor-09-dusk-theme-specificatie-v01.md`
|
||||||
|
gelinkt vanuit `docs/README.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12 · Bestanden die wijzigen
|
||||||
|
|
||||||
|
| Bestand | Wijziging |
|
||||||
|
| --- | --- |
|
||||||
|
| `app/globals.css` | Vervangen (§8.1) |
|
||||||
|
| `app/layout.tsx` | Fonts + Providers wrap (§8.2, §8.3) |
|
||||||
|
| `app/theme-provider.tsx` | Nieuw (§8.3) |
|
||||||
|
| `components/ui/theme-toggle.tsx` | Nieuw (§8.4) |
|
||||||
|
| `app/page.tsx` | Class cleanup (§8.5, §8.6) |
|
||||||
|
| `app/dashboard/page.tsx` | Cleanup + `ThemeToggle` |
|
||||||
|
| `app/check-in/**/*.tsx` | Cleanup + Lucide `Sun` icon |
|
||||||
|
| `app/planning/**/*.tsx` | Cleanup + Lucide `Calendar` / `Plus` |
|
||||||
|
| `app/settings/page.tsx` | Cleanup + `ThemeToggle` |
|
||||||
|
| `app/onboarding/**/*.tsx` | Cleanup |
|
||||||
|
| `components/check-in/*.tsx` | Tokens + `aria-label` op scale-buttons |
|
||||||
|
| `components/planning/energy-meter-card.tsx` | Tokens + `tabular-nums` + reduced-motion |
|
||||||
|
| `app/styleguide/page.tsx` | Nieuw — primitives preview (flag-gated) |
|
||||||
|
| `docs/README.md` | Link naar deze spec toevoegen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13 · Wijzigingslog
|
||||||
|
|
||||||
|
| Versie | Datum | Wijziging |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| v0.1 | 2026-04-19 | Initiële Dusk-specificatie. Variant gekozen na exploratie van 4 blue-leaning opties (Dusk, Harbor, Linen, Meridian). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dit document is de normatieve bron voor het Dusk-thema. Wijzigingen
|
||||||
|
vereisen versiebump en een corresponderende entry in §13.*
|
||||||
59
docs/inspannings-monitor-dagelijkse-deploy-checklist.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Inspannings Monitor Dagelijkse Deploy Checklist
|
||||||
|
|
||||||
|
Deze checklist is bedoeld voor de normale werkflow van branch naar productie.
|
||||||
|
|
||||||
|
## 1. Werk starten
|
||||||
|
|
||||||
|
1. Maak een nieuwe branch vanaf `main`.
|
||||||
|
2. Geef de branch een duidelijke naam, bijvoorbeeld `feature/st-201-ochtend-checkin`.
|
||||||
|
3. Werk lokaal en controleer tussendoor met:
|
||||||
|
- `npm run lint`
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
## 2. Wijzigingen publiceren
|
||||||
|
|
||||||
|
1. Commit je werk lokaal.
|
||||||
|
2. Push de branch naar GitHub.
|
||||||
|
3. Open een pull request naar `main`.
|
||||||
|
|
||||||
|
## 3. CI controleren
|
||||||
|
|
||||||
|
1. Open de pull request in GitHub.
|
||||||
|
2. Controleer of de verplichte status check `Lint and build` groen is.
|
||||||
|
3. Merge niet zolang deze check faalt.
|
||||||
|
|
||||||
|
## 4. Preview deployment controleren
|
||||||
|
|
||||||
|
1. Open de Vercel preview deployment die aan de pull request hangt.
|
||||||
|
2. Controleer minimaal:
|
||||||
|
- landingpagina `/`
|
||||||
|
- login `/login`
|
||||||
|
- signup `/sign-up`
|
||||||
|
- dashboard `/dashboard`
|
||||||
|
3. Controleer bij auth-wijzigingen ook de bevestigingsflow via `/auth/confirm`.
|
||||||
|
|
||||||
|
## 5. Merge naar productie
|
||||||
|
|
||||||
|
1. Merge de pull request naar `main`.
|
||||||
|
2. Wacht tot Vercel automatisch de production deployment uitvoert.
|
||||||
|
3. Controleer daarna productie op:
|
||||||
|
- [inspannings-monitor.jp-visser.nl](https://inspannings-monitor.jp-visser.nl)
|
||||||
|
- login/signup
|
||||||
|
- dashboard
|
||||||
|
- settings
|
||||||
|
|
||||||
|
## 6. Bij problemen
|
||||||
|
|
||||||
|
1. Open de laatste deployment in Vercel.
|
||||||
|
2. Controleer build logs en runtime logs.
|
||||||
|
3. Revert de merge in GitHub als productie echt stuk is.
|
||||||
|
4. Laat Vercel daarna automatisch opnieuw deployen vanaf de herstelde `main`.
|
||||||
|
|
||||||
|
## 7. Huidige projectafspraken
|
||||||
|
|
||||||
|
- `main` is beschermd
|
||||||
|
- pull requests zijn verplicht
|
||||||
|
- status check `Lint and build` is verplicht
|
||||||
|
- force pushes naar `main` zijn geblokkeerd
|
||||||
|
- branch deletion van `main` is geblokkeerd
|
||||||
|
- productie draait op [inspannings-monitor.jp-visser.nl](https://inspannings-monitor.jp-visser.nl)
|
||||||
80
docs/inspannings-monitor-ops-security-notitie.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Inspannings Monitor Ops en Security Notitie
|
||||||
|
|
||||||
|
Datum: `2026-04-18`
|
||||||
|
|
||||||
|
Deze notitie legt de actuele operationele en security-besluiten vast rond
|
||||||
|
repositorybeheer, deployment en secrets.
|
||||||
|
|
||||||
|
## 1. Huidige operationele status
|
||||||
|
|
||||||
|
- GitHub-repository: `public`
|
||||||
|
- Standaardbranch: `main`
|
||||||
|
- CI: GitHub Actions workflow `CI`
|
||||||
|
- Verplichte status check op `main`: `Lint and build`
|
||||||
|
- Productiehosting: `Vercel`
|
||||||
|
- Productiedomein: [inspannings-monitor.jp-visser.nl](https://inspannings-monitor.jp-visser.nl)
|
||||||
|
|
||||||
|
## 2. Branch protection
|
||||||
|
|
||||||
|
De branch `main` is beschermd met:
|
||||||
|
|
||||||
|
- pull requests verplicht
|
||||||
|
- required status check `Lint and build`
|
||||||
|
- force pushes geblokkeerd
|
||||||
|
- branch deletion geblokkeerd
|
||||||
|
|
||||||
|
Bewuste huidige keuze:
|
||||||
|
|
||||||
|
- `Require branches to be up to date before merging` staat niet verplicht aan
|
||||||
|
|
||||||
|
Dat is voor de huidige projectfase acceptabel en houdt de flow eenvoudig.
|
||||||
|
|
||||||
|
## 3. Vercel en deploymentbeleid
|
||||||
|
|
||||||
|
De gekozen deployroute is:
|
||||||
|
|
||||||
|
- feature branches en pull requests krijgen preview deployments via Vercel
|
||||||
|
- merges naar `main` geven een automatische production deployment
|
||||||
|
|
||||||
|
Voor deze frontend-app worden in Vercel alleen publieke Supabase-variabelen gebruikt:
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`
|
||||||
|
|
||||||
|
Er hoort geen `service_role` of andere admin-key in Vercel te staan.
|
||||||
|
|
||||||
|
## 4. Secret-incident en respons
|
||||||
|
|
||||||
|
Tijdens de setupfase heeft lokaal een Supabase `service_role` key in een
|
||||||
|
omgevingsbestand gestaan en daarmee tijdelijk in git-tracking gezeten.
|
||||||
|
|
||||||
|
Reeds genomen maatregelen:
|
||||||
|
|
||||||
|
- `.env` en `.env.local` zijn uit git-tracking gehaald
|
||||||
|
- `.gitignore` is aangescherpt zodat lokale env-bestanden niet opnieuw meegaan
|
||||||
|
- de applicatiecode gebruikt geen `service_role` key
|
||||||
|
- de frontend gebruikt alleen de publishable key
|
||||||
|
- in het Supabase-dashboard stond de legacy JWT-keysectie op het moment van controle uitgeschakeld
|
||||||
|
|
||||||
|
## 5. Resterende security-aandachtspunten
|
||||||
|
|
||||||
|
Deze punten zijn nog belangrijk, ook als de app nu functioneel goed draait:
|
||||||
|
|
||||||
|
1. Behandel de eerder gebruikte `service_role` key als gecompromitteerd.
|
||||||
|
2. Gebruik die key nergens meer opnieuw.
|
||||||
|
3. Gebruik voor toekomstige server/admin-taken alleen een nieuwe secret-key als dat echt nodig is.
|
||||||
|
4. Overweeg de oude secret ook uit de Git-history te verwijderen als je de repositoryhistorie volledig wilt opschonen.
|
||||||
|
|
||||||
|
## 6. Praktische beheerafspraken
|
||||||
|
|
||||||
|
- secrets nooit in de repository opslaan
|
||||||
|
- `.env.example` alleen als template gebruiken
|
||||||
|
- deploys alleen via GitHub + Vercel laten lopen
|
||||||
|
- wijzigingen naar productie via pull request en `main`
|
||||||
|
- production altijd kort valideren na merge
|
||||||
|
|
||||||
|
## 7. Relevante documenten
|
||||||
|
|
||||||
|
- [inspannings-monitor-cicd-en-deploy.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-cicd-en-deploy.md)
|
||||||
|
- [inspannings-monitor-dagelijkse-deploy-checklist.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-dagelijkse-deploy-checklist.md)
|
||||||
|
- [README.md](/Users/janpetervisser/Development/third/README.md)
|
||||||
BIN
docs/persona-avatars/image1.jpg
Normal file
|
After Width: | Height: | Size: 511 KiB |
BIN
docs/persona-avatars/image2.jpg
Normal file
|
After Width: | Height: | Size: 600 KiB |
BIN
docs/persona-avatars/image3.jpg
Normal file
|
After Width: | Height: | Size: 517 KiB |
BIN
docs/persona-avatars/image4.jpg
Normal file
|
After Width: | Height: | Size: 540 KiB |
BIN
docs/persona-avatars/image5.jpg
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
docs/persona-avatars/image6.jpg
Normal file
|
After Width: | Height: | Size: 558 KiB |
BIN
docs/persona-avatars/image7.jpg
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
docs/persona-avatars/image8.jpg
Normal file
|
After Width: | Height: | Size: 566 KiB |