Merge pull request #1 from madhura68/feat/wizard-toast-validation

Add wizard flows, toast feedback, and strict form validation
This commit is contained in:
Janpeter Visser 2026-04-19 16:06:08 +02:00 committed by GitHub
commit 645d2b8b3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 12416 additions and 886 deletions

View file

@ -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

View file

@ -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
View file

@ -1 +1 @@
20.9.0 20.19.0

84
CLAUDE.md Normal file
View 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

View file

@ -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
View 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 misbruikscenarios.
**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-401405 (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-risicos 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View file

@ -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
View 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

View file

@ -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>
); );
} }

View file

@ -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

View file

@ -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;
} }

View file

@ -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>
); );
} }

View file

@ -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
View 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
View 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
View 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
View 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>
);
}

View file

@ -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;
} }

View file

@ -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>
); );
} }

View file

@ -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
View 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>
);
}

View file

@ -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}

View file

@ -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

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
); );
} }

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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"}
/>
</>
);
}

View 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>
);
}

View file

@ -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>

View 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;
}

View file

@ -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}

View file

@ -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",
}, },
}, },

View file

@ -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,
} }

View file

@ -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}

View 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,
};

View file

@ -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}

View file

@ -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 />

View 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
View 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 };

View file

@ -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}

View 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 };

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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

View file

@ -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

View file

@ -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
View 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
View 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
View 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)

View 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.

View 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

View 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

View 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

Binary file not shown.

View 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.651.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 (110 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.*

View 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)

View 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Some files were not shown because too many files have changed in this diff Show more