From 000d2351c1603830ac6ac0896f9c54c63ac77271 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 18 Apr 2026 18:14:36 +0200 Subject: [PATCH 01/31] Add wizard flows, toast feedback, and strict form validation --- .env.example | 1 + README.md | 9 + app/auth-actions.ts | 94 +++- app/dashboard/page.tsx | 64 +-- app/layout.tsx | 6 +- app/login/page.tsx | 19 +- app/onboarding/actions.ts | 76 ++- app/onboarding/page.tsx | 16 +- app/page.tsx | 19 +- app/settings/actions.ts | 70 ++- app/settings/page.tsx | 37 +- app/sign-up/page.tsx | 19 +- app/wizard-test/page.tsx | 31 ++ components/feedback/status-toast-bridge.tsx | 53 +++ components/onboarding/onboarding-flow.tsx | 435 ++++++------------ .../onboarding/onboarding-step-intro.tsx | 41 ++ .../onboarding-step-preferences.tsx | 106 +++++ .../onboarding/onboarding-step-profile.tsx | 74 +++ .../preferences/preference-hidden-fields.tsx | 31 ++ components/settings/settings-form.tsx | 91 ++-- components/ui/sonner.tsx | 42 ++ components/wizard/test-wizard-flow.tsx | 146 ++++++ components/wizard/wizard-progress.tsx | 22 + components/wizard/wizard-shell.tsx | 63 +++ docs/README.md | 6 + docs/generate_inspannings_monitor_docs.py | 393 ++++++++++++++++ docs/gpt-instructies.md | 111 +++++ ...-01-productkader-en-positionering-v06.docx | Bin 40445 -> 40445 bytes ...r-02-functionele-specificatie-mvp-v06.docx | Bin 41048 -> 41048 bytes ...-privacy-security-safety-baseline-v02.docx | Bin 41520 -> 41520 bytes ...-04-roadmap-wellness-naar-medisch-v02.docx | Bin 40018 -> 40018 bytes ...che-architectuur-en-implementatie-v01.docx | Bin 43049 -> 43049 bytes ...r-06-implementatieplan-en-backlog-v01.docx | Bin 42476 -> 42476 bytes ...ngs-monitor-dagelijkse-deploy-checklist.md | 59 +++ ...nspannings-monitor-ops-security-notitie.md | 80 ++++ lib/auth/messages.ts | 8 + lib/config/feature-flags.ts | 3 + lib/feedback/status-messages.ts | 105 +++++ lib/feedback/toast.ts | 26 ++ lib/forms/parse.ts | 117 +++++ lib/onboarding/use-onboarding-draft.ts | 40 ++ lib/preferences/use-preferences-draft.ts | 40 ++ lib/search-params.ts | 6 + lib/wizard/types.ts | 19 + lib/wizard/use-wizard-flow.ts | 67 +++ package-lock.json | 22 + package.json | 2 + 47 files changed, 2169 insertions(+), 500 deletions(-) create mode 100644 app/wizard-test/page.tsx create mode 100644 components/feedback/status-toast-bridge.tsx create mode 100644 components/onboarding/onboarding-step-intro.tsx create mode 100644 components/onboarding/onboarding-step-preferences.tsx create mode 100644 components/onboarding/onboarding-step-profile.tsx create mode 100644 components/preferences/preference-hidden-fields.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/wizard/test-wizard-flow.tsx create mode 100644 components/wizard/wizard-progress.tsx create mode 100644 components/wizard/wizard-shell.tsx create mode 100644 docs/gpt-instructies.md create mode 100644 docs/inspannings-monitor-dagelijkse-deploy-checklist.md create mode 100644 docs/inspannings-monitor-ops-security-notitie.md create mode 100644 lib/config/feature-flags.ts create mode 100644 lib/feedback/status-messages.ts create mode 100644 lib/feedback/toast.ts create mode 100644 lib/forms/parse.ts create mode 100644 lib/onboarding/use-onboarding-draft.ts create mode 100644 lib/preferences/use-preferences-draft.ts create mode 100644 lib/search-params.ts create mode 100644 lib/wizard/types.ts create mode 100644 lib/wizard/use-wizard-flow.ts diff --git a/.env.example b/.env.example index 4191d20..b89112c 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key_here +NEXT_PUBLIC_ENABLE_TEST_WIZARD=false diff --git a/README.md b/README.md index 4868ea2..3f99b90 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: 2. Vul in: - `NEXT_PUBLIC_SUPABASE_URL` - `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` + - optioneel: `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` voor de interne wizard-testpagina 3. Installeer dependencies met `npm install` 4. Start lokaal met `npm run dev` @@ -77,6 +78,14 @@ de profile/settings-laag lokaal test. De app gebruikt `shadcn/ui` bovenop `Tailwind CSS` als herbruikbare basis voor knoppen, formulieren, kaarten en meldingen. De theme tokens staan centraal in `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. + +## 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 diff --git a/app/auth-actions.ts b/app/auth-actions.ts index 5489b41..2545a89 100644 --- a/app/auth-actions.ts +++ b/app/auth-actions.ts @@ -7,28 +7,76 @@ import { getRequestOrigin, sanitizeNextPath, } from "@/lib/auth/navigation"; +import { + assertEmail, + assertMinLength, + FormDataValidationError, + getOptionalString, + getRequiredString, +} from "@/lib/forms/parse"; import { createClient } from "@/lib/supabase/server"; import { hasSupabaseEnv } from "@/lib/supabase/config"; -function getString(formData: FormData, key: string) { - const value = formData.get(key); - return typeof value === "string" ? value.trim() : ""; +function parseSignInFormData(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, + }; +} + +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) { - 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()) { 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 { error } = await supabase.auth.signInWithPassword({ email, @@ -50,7 +98,22 @@ export async function signInAction(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()) { 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 headerStore = await headers(); const origin = getRequestOrigin(headerStore); diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 69f3a22..dd53b1f 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { signOutAction } from "@/app/auth-actions"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -12,23 +12,18 @@ import { } from "@/components/ui/card"; import { sanitizeNextPath } from "@/lib/auth/navigation"; import { getAuthState } from "@/lib/auth/session"; +import { isTestWizardEnabled } from "@/lib/config/feature-flags"; +import { getDashboardStatusToast } from "@/lib/feedback/status-messages"; import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; import { cn } from "@/lib/utils"; export const dynamic = "force-dynamic"; type DashboardPageProps = { - searchParams: Promise>; + searchParams: Promise; }; -function getParamValue( - params: Record, - key: string, -) { - const value = params[key]; - return typeof value === "string" ? value : null; -} - function formatToggleState(value: boolean, enabledLabel = "Aan", disabledLabel = "Uit") { return value ? enabledLabel : disabledLabel; } @@ -37,18 +32,6 @@ function formatReminderTime(value: string | null) { 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) { const authState = await getAuthState(); const resolvedSearchParams = await searchParams; @@ -68,7 +51,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps } const { profile, settings } = profileBundle; - const notice = getDashboardNotice(getParamValue(resolvedSearchParams, "status")); + const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status")); if (!profile.onboardingSeen) { redirect("/onboarding"); @@ -83,13 +66,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps return (
- {notice ? ( - - - {notice} - - - ) : null} +
@@ -117,6 +94,17 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps > Instellingen + {isTestWizardEnabled() ? ( + + Test wizard + + ) : null} @@ -185,6 +173,22 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps + + {isTestWizardEnabled() ? ( + + +

+ Wizard core +

+ Interne testwizard actief +
+ + + Gebruik deze alleen in development of preview om nieuwe multi-step flows te controleren. + + +
+ ) : null} {!profile.onboardingCompleted ? ( diff --git a/app/layout.tsx b/app/layout.tsx index 683c110..d3ff4a6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Toaster } from "@/components/ui/sonner"; import "./globals.css"; export const metadata: Metadata = { @@ -14,7 +15,10 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + + ); } diff --git a/app/login/page.tsx b/app/login/page.tsx index 5f07585..1cdf584 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,30 +1,23 @@ import Link from "next/link"; 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 { signInAction } from "@/app/auth-actions"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { getAuthNotice } from "@/lib/auth/messages"; import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation"; 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"; type LoginPageProps = { - searchParams: Promise>; + searchParams: Promise; }; -function getParamValue( - params: Record, - key: string, -) { - const value = params[key]; - return typeof value === "string" ? value : null; -} - export default async function LoginPage({ searchParams }: LoginPageProps) { const authState = await getAuthState(); const resolvedSearchParams = await searchParams; @@ -34,7 +27,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { redirect(next); } - const notice = getAuthNotice( + const statusToast = getAuthStatusToast( getParamValue(resolvedSearchParams, "error"), getParamValue(resolvedSearchParams, "status"), ); @@ -54,7 +47,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {

} > - + {!authState.isConfigured ? ( diff --git a/app/onboarding/actions.ts b/app/onboarding/actions.ts index 389795f..4b8e1e2 100644 --- a/app/onboarding/actions.ts +++ b/app/onboarding/actions.ts @@ -2,38 +2,80 @@ import { redirect } from "next/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 { completeOnboardingForCurrentUser, markOnboardingSeenForCurrentUser, } from "@/lib/profile/service"; import type { OnboardingSubmission } from "@/lib/profile/types"; -function getString(formData: FormData, key: string) { - const value = formData.get(key); - return typeof value === "string" ? value.trim() : ""; -} - -function getBoolean(formData: FormData, key: string) { - return formData.get(key) === "true"; -} +const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value); function buildOnboardingSubmission(formData: FormData): OnboardingSubmission { + const morningReminderEnabled = getBooleanValue( + formData, + "morningReminderEnabled", + "invalid-onboarding-input", + ); + const reminderTime = getOptionalTimeValue( + formData, + "morningReminderTime", + "invalid-onboarding-input", + ); + return { - displayName: getString(formData, "displayName") || null, - timezone: getString(formData, "timezone"), - morningReminderEnabled: getBoolean(formData, "morningReminderEnabled"), - morningReminderTime: getString(formData, "morningReminderTime") || null, - reflectionReminderEnabled: getBoolean(formData, "reflectionReminderEnabled"), - showEnergyPoints: getBoolean(formData, "showEnergyPoints"), + displayName: getOptionalString(formData, "displayName") || null, + timezone: getEnumValue( + formData, + "timezone", + ONBOARDING_TIMEZONE_VALUES, + "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) { - await completeOnboardingForCurrentUser(buildOnboardingSubmission(formData)); +export async function completeOnboardingAction( + _previousState: null, + formData: FormData, +): Promise { + 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" })); + return null; } -export async function skipOnboardingAction() { +export async function skipOnboardingAction( + _previousState: null, + _formData: FormData, +): Promise { await markOnboardingSeenForCurrentUser(); redirect(buildPathWithQuery("/dashboard", { status: "onboarding-skipped" })); + return null; } diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index 2e48884..187572f 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -1,13 +1,21 @@ import { redirect } from "next/navigation"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; import { sanitizeNextPath } from "@/lib/auth/navigation"; import { getAuthState } from "@/lib/auth/session"; +import { getOnboardingStatusToast } from "@/lib/feedback/status-messages"; import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; export const dynamic = "force-dynamic"; -export default async function OnboardingPage() { +type OnboardingPageProps = { + searchParams: Promise; +}; + +export default async function OnboardingPage({ searchParams }: OnboardingPageProps) { const authState = await getAuthState(); + const resolvedSearchParams = await searchParams; if (!authState.isConfigured) { redirect("/login?error=auth-not-configured"); @@ -27,9 +35,15 @@ export default async function OnboardingPage() { redirect("/dashboard"); } + const statusToast = getOnboardingStatusToast( + getParamValue(resolvedSearchParams, "error"), + getParamValue(resolvedSearchParams, "status"), + ); + return (
+
diff --git a/app/page.tsx b/app/page.tsx index de51a24..7ac5a2c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { signOutAction } from "@/app/auth-actions"; -import { AuthNotice } from "@/components/auth/auth-notice"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -9,8 +9,9 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { getAuthNotice } from "@/lib/auth/messages"; import { getAuthState } from "@/lib/auth/session"; +import { getAuthStatusToast } from "@/lib/feedback/status-messages"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; import { cn } from "@/lib/utils"; export const dynamic = "force-dynamic"; @@ -38,21 +39,13 @@ const releaseFocus = [ ]; type HomePageProps = { - searchParams: Promise>; + searchParams: Promise; }; -function getParamValue( - params: Record, - key: string, -) { - const value = params[key]; - return typeof value === "string" ? value : null; -} - export default async function Home({ searchParams }: HomePageProps) { const authState = await getAuthState(); const resolvedSearchParams = await searchParams; - const notice = getAuthNotice( + const statusToast = getAuthStatusToast( getParamValue(resolvedSearchParams, "error"), getParamValue(resolvedSearchParams, "status"), ); @@ -118,7 +111,7 @@ export default async function Home({ searchParams }: HomePageProps) {
- +
diff --git a/app/settings/actions.ts b/app/settings/actions.ts index e68902f..4a661f4 100644 --- a/app/settings/actions.ts +++ b/app/settings/actions.ts @@ -2,30 +2,68 @@ import { redirect } from "next/navigation"; import { buildPathWithQuery } from "@/lib/auth/navigation"; +import { + FormDataValidationError, + getBooleanValue, + getEnumValue, + getOptionalTimeValue, +} from "@/lib/forms/parse"; +import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; import { saveSettingsForCurrentUser } from "@/lib/profile/service"; import type { SettingsSubmission } from "@/lib/profile/types"; -function getString(formData: FormData, key: string) { - const value = formData.get(key); - return typeof value === "string" ? value.trim() : ""; -} - -function getBoolean(formData: FormData, key: string) { - return formData.get(key) === "true"; -} +const LOCALE_VALUES = ["nl-NL"] as const; +const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value); function buildSettingsSubmission(formData: FormData): SettingsSubmission { + const morningReminderEnabled = getBooleanValue( + formData, + "morningReminderEnabled", + "invalid-settings-input", + ); + const reminderTime = getOptionalTimeValue( + formData, + "morningReminderTime", + "invalid-settings-input", + ); + return { - locale: getString(formData, "locale"), - timezone: getString(formData, "timezone"), - morningReminderEnabled: getBoolean(formData, "morningReminderEnabled"), - morningReminderTime: getString(formData, "morningReminderTime") || null, - reflectionReminderEnabled: getBoolean(formData, "reflectionReminderEnabled"), - showEnergyPoints: getBoolean(formData, "showEnergyPoints"), + 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) { - await saveSettingsForCurrentUser(buildSettingsSubmission(formData)); +export async function saveSettingsAction( + _previousState: null, + formData: FormData, +): Promise { + try { + await saveSettingsForCurrentUser(buildSettingsSubmission(formData)); + } catch (error) { + if (error instanceof FormDataValidationError) { + redirect(buildPathWithQuery("/settings", { error: error.code })); + } + + throw error; + } + redirect(buildPathWithQuery("/settings", { status: "saved" })); + return null; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index e251070..12598aa 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { signOutAction } from "@/app/auth-actions"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { SettingsForm } from "@/components/settings/settings-form"; -import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -13,31 +13,17 @@ import { } from "@/components/ui/card"; import { sanitizeNextPath } from "@/lib/auth/navigation"; import { getAuthState } from "@/lib/auth/session"; +import { getSettingsStatusToast } from "@/lib/feedback/status-messages"; import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; import { cn } from "@/lib/utils"; export const dynamic = "force-dynamic"; type SettingsPageProps = { - searchParams: Promise>; + searchParams: Promise; }; -function getParamValue( - params: Record, - 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) { const authState = await getAuthState(); const resolvedSearchParams = await searchParams; @@ -60,7 +46,10 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps) redirect("/onboarding"); } - const notice = getSettingsNotice(getParamValue(resolvedSearchParams, "status")); + const statusToast = getSettingsStatusToast( + getParamValue(resolvedSearchParams, "error"), + getParamValue(resolvedSearchParams, "status"), + ); const profileTitle = profileBundle.profile.displayName ?? profileBundle.profile.email ?? @@ -70,6 +59,8 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps) return (
+ +
@@ -106,14 +97,6 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
- {notice ? ( - - - {notice} - - - ) : null} -
diff --git a/app/sign-up/page.tsx b/app/sign-up/page.tsx index 8f101bb..6cb17a0 100644 --- a/app/sign-up/page.tsx +++ b/app/sign-up/page.tsx @@ -1,30 +1,23 @@ import Link from "next/link"; 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 { signUpAction } from "@/app/auth-actions"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { getAuthNotice } from "@/lib/auth/messages"; import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation"; 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"; type SignUpPageProps = { - searchParams: Promise>; + searchParams: Promise; }; -function getParamValue( - params: Record, - key: string, -) { - const value = params[key]; - return typeof value === "string" ? value : null; -} - export default async function SignUpPage({ searchParams }: SignUpPageProps) { const authState = await getAuthState(); const resolvedSearchParams = await searchParams; @@ -34,7 +27,7 @@ export default async function SignUpPage({ searchParams }: SignUpPageProps) { redirect(next); } - const notice = getAuthNotice( + const statusToast = getAuthStatusToast( getParamValue(resolvedSearchParams, "error"), getParamValue(resolvedSearchParams, "status"), ); @@ -54,7 +47,7 @@ export default async function SignUpPage({ searchParams }: SignUpPageProps) {

} > - + {!authState.isConfigured ? ( diff --git a/app/wizard-test/page.tsx b/app/wizard-test/page.tsx new file mode 100644 index 0000000..b5a9794 --- /dev/null +++ b/app/wizard-test/page.tsx @@ -0,0 +1,31 @@ +import { redirect } from "next/navigation"; +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 ( +
+
+ +
+
+ ); +} diff --git a/components/feedback/status-toast-bridge.tsx b/components/feedback/status-toast-bridge.tsx new file mode 100644 index 0000000..4934870 --- /dev/null +++ b/components/feedback/status-toast-bridge.tsx @@ -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; +} diff --git a/components/onboarding/onboarding-flow.tsx b/components/onboarding/onboarding-flow.tsx index 466d5de..76f6289 100644 --- a/components/onboarding/onboarding-flow.tsx +++ b/components/onboarding/onboarding-flow.tsx @@ -1,49 +1,41 @@ "use client"; -import type { MouseEvent } from "react"; -import { useState } from "react"; +import { useActionState } from "react"; 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 { Button } 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 { Switch } from "@/components/ui/switch"; -import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; +import { WizardShell } from "@/components/wizard/wizard-shell"; +import { useOnboardingDraft, type OnboardingDraft } from "@/lib/onboarding/use-onboarding-draft"; import type { ProfileBundle } from "@/lib/profile/types"; +import { useWizardFlow } from "@/lib/wizard/use-wizard-flow"; +import type { WizardStepDefinition } from "@/lib/wizard/types"; type OnboardingFlowProps = { profileBundle: ProfileBundle; }; -const steps = [ +const steps: WizardStepDefinition[] = [ { + id: "intro", eyebrow: "Stap 1", title: "Zo gebruiken we Inspannings Monitor", description: "De app helpt je om je dag rustiger te plannen en terug te kijken zonder medische claims of zorgverlenerfuncties.", }, { + id: "profile", eyebrow: "Stap 2", title: "Basisprofiel", description: "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", title: "Startvoorkeuren", description: @@ -51,292 +43,133 @@ const steps = [ }, ] as const; +function renderCurrentStep( + stepId: string, + draft: OnboardingDraft, + updateDraft: (patch: Partial) => void, + disabled: boolean, +) { + switch (stepId) { + case "intro": + return ; + case "profile": + return ( + + ); + case "preferences": + return ( + + ); + default: + return null; + } +} + export function OnboardingFlow({ profileBundle }: OnboardingFlowProps) { - const [currentStep, setCurrentStep] = useState(0); - const [displayName, setDisplayName] = useState(profileBundle.profile.displayName ?? ""); - const [timezone, setTimezone] = useState(profileBundle.profile.timezone); - const [showEnergyPoints, setShowEnergyPoints] = useState( - profileBundle.settings.showEnergyPoints, - ); - const [morningReminderEnabled, setMorningReminderEnabled] = useState( - profileBundle.settings.morningReminderEnabled, - ); - const [morningReminderTime, setMorningReminderTime] = useState( - profileBundle.settings.morningReminderTime ?? "08:30", - ); - const [reflectionReminderEnabled, setReflectionReminderEnabled] = useState( - profileBundle.settings.reflectionReminderEnabled, + const [, completeFormAction, isCompleting] = useActionState(completeOnboardingAction, null); + const [, skipFormAction, isSkipping] = useActionState(skipOnboardingAction, null); + const { draft, updateDraft } = useOnboardingDraft(profileBundle); + const wizard = useWizardFlow({ + steps, + draft, + }); + const isPending = isCompleting || isSkipping; + + const aside = ( + + + Release 1 blijft bewust wellness-first. + + Alleen voor individuele gebruikers, zonder delen of zorgverlenerstoegang. + + + De app geeft geen diagnose, behandeling of medisch advies. + + + Bij acute of snel verslechterende klachten hoort directe hulp via arts, huisartsenpost of 112 buiten deze app. + + + ); - const step = steps[currentStep]; - const isFirstStep = currentStep === 0; - const isLastStep = currentStep === steps.length - 1; + const topAction = ( + <> +

+ Korte onboarding +

+
+ +
+ + ); - function goToPreviousStep() { - setCurrentStep((stepIndex) => Math.max(0, stepIndex - 1)); - } + const backAction = ( + + ); - function goToNextStep(event: MouseEvent) { - event.preventDefault(); - setCurrentStep((stepIndex) => Math.min(steps.length - 1, stepIndex + 1)); - } + const nextAction = wizard.isLastStep ? ( + + ) : ( + + ); return ( -
-
-

- {step.eyebrow} -

-

- {step.title} -

-

- {step.description} -

+ +
+ + - - - Release 1 blijft bewust wellness-first. - - Alleen voor individuele gebruikers, zonder delen of zorgverlenerstoegang. - - - De app geeft geen diagnose, behandeling of medisch advies. - - - Bij acute of snel verslechterende klachten hoort directe hulp via arts, huisartsenpost of 112 buiten deze app. - - - - -
    - {steps.map((item, index) => ( -
  1. - ))} -
-
- -
-
-

- Korte onboarding -

- - - -
- -
- - - - - - - - {currentStep === 0 ? ( -
- - - - Wat je hier wél krijgt - - - - - Een rustige plan-doe-evalueer flow met energiebudgetten, zonder - druk, score-oordeel of medische terminologie. - - - - - - - - Wat deze app niet doet - - - - - Geen diagnose, geen behandeling, geen medische triage en geen - automatisch delen met derden. - - - -
- ) : null} - - {currentStep === 1 ? ( -
-
- - setDisplayName(event.target.value)} - placeholder="Optioneel, bijvoorbeeld Jan" - maxLength={40} - /> -
- - - - Voertaal voor release 1 staat vast op Nederlands. - - - -
- - -
-
- ) : null} - - {currentStep === 2 ? ( -
- - -
- -

- Laat geplande en resterende punten zichtbaar zien in de interface. -

-
- -
-
- - - -
-
- -

- Handig als je later een korte check-in wilt doen zonder extra druk. -

-
- -
- - {morningReminderEnabled ? ( - <> - -
- - setMorningReminderTime(event.target.value)} - /> -
- - ) : null} -
-
- - - -
- -

- Optionele terugblikprompts kunnen later helpen om rustiger patronen te zien. -

-
- -
-
-
- ) : null} - - - -
- - - {isLastStep ? ( - - ) : ( - - )} -
- -
-
+ {renderCurrentStep(wizard.currentStep.id, draft, updateDraft, isPending)} + + ); } diff --git a/components/onboarding/onboarding-step-intro.tsx b/components/onboarding/onboarding-step-intro.tsx new file mode 100644 index 0000000..897de83 --- /dev/null +++ b/components/onboarding/onboarding-step-intro.tsx @@ -0,0 +1,41 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export function OnboardingStepIntro() { + return ( +
+ + + + Wat je hier wél krijgt + + + + + Een rustige plan-doe-evalueer flow met energiebudgetten, zonder druk, + score-oordeel of medische terminologie. + + + + + + + + Wat deze app niet doet + + + + + Geen diagnose, geen behandeling, geen medische triage en geen automatisch + delen met derden. + + + +
+ ); +} diff --git a/components/onboarding/onboarding-step-preferences.tsx b/components/onboarding/onboarding-step-preferences.tsx new file mode 100644 index 0000000..a529f30 --- /dev/null +++ b/components/onboarding/onboarding-step-preferences.tsx @@ -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) => void; + disabled?: boolean; +}; + +export function OnboardingStepPreferences({ + draft, + updateDraft, + disabled = false, +}: OnboardingStepPreferencesProps) { + return ( +
+ + +
+ +

+ Laat geplande en resterende punten zichtbaar zien in de interface. +

+
+ updateDraft({ showEnergyPoints: checked })} + /> +
+
+ + + +
+
+ +

+ Handig als je later een korte check-in wilt doen zonder extra druk. +

+
+ + updateDraft({ morningReminderEnabled: checked }) + } + /> +
+ + {draft.morningReminderEnabled ? ( + <> + +
+ + + updateDraft({ morningReminderTime: event.target.value }) + } + /> +
+ + ) : null} +
+
+ + + +
+ +

+ Optionele terugblikprompts kunnen later helpen om rustiger patronen te zien. +

+
+ + updateDraft({ reflectionReminderEnabled: checked }) + } + /> +
+
+
+ ); +} diff --git a/components/onboarding/onboarding-step-profile.tsx b/components/onboarding/onboarding-step-profile.tsx new file mode 100644 index 0000000..c40e796 --- /dev/null +++ b/components/onboarding/onboarding-step-profile.tsx @@ -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) => void; + disabled?: boolean; +}; + +export function OnboardingStepProfile({ + draft, + updateDraft, + disabled = false, +}: OnboardingStepProfileProps) { + return ( +
+
+ + updateDraft({ displayName: event.target.value })} + placeholder="Optioneel, bijvoorbeeld Jan" + maxLength={40} + /> +
+ + + + Voertaal voor release 1 staat vast op Nederlands. + + + +
+ + +
+
+ ); +} diff --git a/components/preferences/preference-hidden-fields.tsx b/components/preferences/preference-hidden-fields.tsx new file mode 100644 index 0000000..90aaeaa --- /dev/null +++ b/components/preferences/preference-hidden-fields.tsx @@ -0,0 +1,31 @@ +import type { PreferenceDraft } from "@/lib/preferences/use-preferences-draft"; + +type PreferenceHiddenFieldsProps = { + draft: PreferenceDraft; +}; + +export function PreferenceHiddenFields({ + draft, +}: PreferenceHiddenFieldsProps) { + return ( + <> + + + + + + + ); +} diff --git a/components/settings/settings-form.tsx b/components/settings/settings-form.tsx index d035f73..ac099b9 100644 --- a/components/settings/settings-form.tsx +++ b/components/settings/settings-form.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useActionState, useState } from "react"; import { saveSettingsAction } from "@/app/settings/actions"; +import { PreferenceHiddenFields } from "@/components/preferences/preference-hidden-fields"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { @@ -23,6 +24,7 @@ import { import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; +import { usePreferenceDraft } from "@/lib/preferences/use-preferences-draft"; import type { ProfileBundle } from "@/lib/profile/types"; type SettingsFormProps = { @@ -37,41 +39,14 @@ const LOCALE_OPTIONS = [ ] as const; export function SettingsForm({ profileBundle }: SettingsFormProps) { + const [, formAction, isPending] = useActionState(saveSettingsAction, null); const [locale, setLocale] = useState(profileBundle.profile.locale); - const [timezone, setTimezone] = useState(profileBundle.profile.timezone); - const [showEnergyPoints, setShowEnergyPoints] = useState( - profileBundle.settings.showEnergyPoints, - ); - const [morningReminderEnabled, setMorningReminderEnabled] = useState( - profileBundle.settings.morningReminderEnabled, - ); - const [morningReminderTime, setMorningReminderTime] = useState( - profileBundle.settings.morningReminderTime ?? "08:30", - ); - const [reflectionReminderEnabled, setReflectionReminderEnabled] = useState( - profileBundle.settings.reflectionReminderEnabled, - ); + const { draft, updateDraft } = usePreferenceDraft(profileBundle); return ( -
+ - - - - - + @@ -107,6 +82,7 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
- setTimezone(value ?? profileBundle.profile.timezone) + updateDraft({ + timezone: value ?? profileBundle.profile.timezone, + }) } > @@ -165,8 +144,11 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
+ updateDraft({ showEnergyPoints }) + } />
@@ -193,14 +175,17 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) { Zet een lichte reminder aan voor een rustige start van je check-in.

- + + updateDraft({ morningReminderEnabled }) + } + />
- {morningReminderEnabled ? ( + {draft.morningReminderEnabled ? ( <>
@@ -210,9 +195,12 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) { setMorningReminderTime(event.target.value)} + value={draft.morningReminderTime} + onChange={(event) => + updateDraft({ morningReminderTime: event.target.value }) + } />
@@ -232,8 +220,11 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) { + updateDraft({ reflectionReminderEnabled }) + } /> @@ -258,11 +249,13 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {

- 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."}

-
diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx new file mode 100644 index 0000000..0f01208 --- /dev/null +++ b/components/ui/sonner.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { + CircleCheckIcon, + InfoIcon, + Loader2Icon, + OctagonXIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/components/wizard/test-wizard-flow.tsx b/components/wizard/test-wizard-flow.tsx new file mode 100644 index 0000000..5a5ad39 --- /dev/null +++ b/components/wizard/test-wizard-flow.tsx @@ -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; + +const steps: WizardStepDefinition[] = [ + { + 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 = { + "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 = ( + + + Interne testwizard + + Alleen bedoeld om de generieke wizard-core te controleren voor toekomstige flows. + + + + ); + + const topAction = ( + <> +

+ Test wizard +

+ + + ); + + const backAction = ( + + ); + + const nextAction = wizard.isLastStep ? ( + + ) : ( + + ); + + return ( + + + + + {wizard.currentStep.title} + + + + + {testStepDescriptions[wizard.currentStep.id]} + + + + + ); +} diff --git a/components/wizard/wizard-progress.tsx b/components/wizard/wizard-progress.tsx new file mode 100644 index 0000000..6536376 --- /dev/null +++ b/components/wizard/wizard-progress.tsx @@ -0,0 +1,22 @@ +import { cn } from "@/lib/utils"; + +type WizardProgressProps = { + current: number; + total: number; +}; + +export function WizardProgress({ current, total }: WizardProgressProps) { + return ( +
    + {Array.from({ length: total }, (_, index) => ( +
  1. + ))} +
+ ); +} diff --git a/components/wizard/wizard-shell.tsx b/components/wizard/wizard-shell.tsx new file mode 100644 index 0000000..18ac220 --- /dev/null +++ b/components/wizard/wizard-shell.tsx @@ -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 ( +
+
+ {eyebrow ? ( +

+ {eyebrow} +

+ ) : null} +

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} + {aside ?
{aside}
: null} + +
+ +
+ {topAction ? ( +
{topAction}
+ ) : null} +
{children}
+ {backAction || nextAction ? ( +
+
{backAction}
+
{nextAction}
+
+ ) : null} +
+
+ ); +} diff --git a/docs/README.md b/docs/README.md index 04cd15b..4121f1a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,12 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem - [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. +- [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. ## Backlog en Linear diff --git a/docs/generate_inspannings_monitor_docs.py b/docs/generate_inspannings_monitor_docs.py index 81fae0d..c4a3741 100644 --- a/docs/generate_inspannings_monitor_docs.py +++ b/docs/generate_inspannings_monitor_docs.py @@ -1193,6 +1193,398 @@ def build_implementatieplan_backlog() -> None: 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", "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__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: BASE_DIR.mkdir(parents=True, exist_ok=True) build_productkader() @@ -1201,6 +1593,7 @@ def main() -> None: build_roadmap() build_technische_architectuur() build_implementatieplan_backlog() + build_testplan() if __name__ == "__main__": diff --git a/docs/gpt-instructies.md b/docs/gpt-instructies.md new file mode 100644 index 0000000..68612c7 --- /dev/null +++ b/docs/gpt-instructies.md @@ -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) diff --git a/docs/inspannings-monitor-01-productkader-en-positionering-v06.docx b/docs/inspannings-monitor-01-productkader-en-positionering-v06.docx index 6e0df119f6ab9eebf5a3939db99d3b2848c3e3d4..e0c2ae511d99feabe814554767ef2cd093ed19b4 100644 GIT binary patch delta 283 zcmeyno9XXvCY}IqW)=|!1_lm>E!`V=&M`7=>E3*YQ65bHWts-2r?QAMfyCFb-Qxq% zeWDd$aSrht`XK%zw|r9&9bEH<8ALla7=Y!A8z+MGv9)|>1By@n-*pW{UGM%5Qgxw6 z6s+ob?@X|+riu5!{G`dBSV79dX4-?O=`+ni)P}AI}@y{Y2rOFKWXwOR*d;|QYS=YY*zFvknTdo#xcL>bKmD=C@_HhRNcUjV*C BW>5eC diff --git a/docs/inspannings-monitor-02-functionele-specificatie-mvp-v06.docx b/docs/inspannings-monitor-02-functionele-specificatie-mvp-v06.docx index c128fccfe2eb6e0bb77b156f7c085023683442e0..fbcea2b121801cf213405c89c39f4d0154701863 100644 GIT binary patch delta 283 zcmca{fa%5oCY}IqW)=|!1_lm>t=$`W&M`7=?cRKcQ65bHWts-2r?QAMfyCFbSqXyZ zSK<|5@f67$`XIiYXTB+jUSIcy8AQ)*G62h;Z=ML&m(=#14JbZ2y7wB0GVc2hQl--` z3RdMjVJ2ADy~%gM{Jm2@v4WIsoNW)HKFu}k+Bm!tRQ6@XWN6QPqWQIl=d835S2B@21KoZ@Se}{0PzgxT7#&( zxo#k8+gz~9uX7zhJo9;WAgX+xBZ%5H4{YY|d0rr%`+OG=)jJ=ot=$`W&M`7=?cRKcQ65bHWts-2r?QAMfyCFbEfoaO zp%Qn&d=u##`XD}^cfKizu5Wn545D*e48Z#4w@w7BGwS%x1{9yH-G2>4F;DmoQuSwo zC|H&7q?uq{>!v=1*f#wWD@a-ATze38YOXn0(>z-cn|XPG7l-LE(9xCx)5yil@xMgXr8A1F*jNtrNlOj5@xv0mUb4_g@21%oDzYRQ;JC z3RWdNX(m|Lx~WegwoU)U3R2cN*B(TjnrjZ$G|v`9InA>HQ4J8@{&^lC9_M^(5al-C z4Mg?L2dg|g-vPwqUtkBK{1-Tas7VXJW?o+41>(ssbOBMR3&Bd3E(9C>c%d%t=$`W&M`7=?cRKcQ65bHWts-2r?QAMfyCFbDe{8o zi^3IPac|KZ`XIilYrZLno>ujS8AP|#8Gz-t)lUTL^KAOg1{9y{)Oig=Np*b(sS@rM z1*_8PnF-c)wEqK`zkT8-R*X1Y0uQk`K7qEcqqfT%?f-oqIlAfEP2YY>$& z(+xyzm10*1W{XNfzA9n%L~MFob3Xl+Gm569G?v~nstsZ04a@R AhX4Qo diff --git a/docs/inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx b/docs/inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx index ef489428486c2527ffe7170b7ad006e967443a18..c80a55371ce8661a0fc73f898dcc756e6cea4428 100644 GIT binary patch delta 283 zcmZ2^fobIhCY}IqW)=|!1_lm>t=$`W&M`7=?cRKcQ65bHWts-2r?QAMfyCFbF-wBz z&GHpsab3k5`XD}gV7@7cu4sG145HJz48Za;x+jA5Y4?3+1By>pnR*RG{h9h5r0VN5 zQLrkW88gAUmd}0)=J(D0#0pZ@wAda*9bRk>*0k6bL|HGf0a2Apz`PwxJU~3ArPd(I zeyJOXYF`SLJGRsT#N$|I2ckTdIfAI}WneSUEb{{KB$m5?sJP`|CG(bpjlQ$o7XYA& BdhGxJ delta 283 zcmZ2^fobIhCY}IqW)=|!1_llWqmqq0=NOrcN;cnNln2v)nWlm1sVw45An|o<%#t8_ zvwQ_uTvzdiK8VjAm~RTAE85;LgXpv_1F-yz?ulT1+I`>IfZ~%?rd|V4f2Mv1sroui z6s(G8#!RrT<+GoH`F(Rgv4WH}Ew%?yhZmcJH7&LUQPxXrKvd-tFmJ~a4-k)OsWphQ zU+M;;+LwamjxBWn@i>;*fhdnt=$`W&M`7=?cRKcQ65bHWts-2r?QAMfyCFbofQMo zwXzjp@$Ygs^g;ZKe)*;#+NtFYGl(|mFaXOZbxs89``Z1T4JbbO-K1+E>e%G(AXWRP zh=NsJn>rJ$t8nH$Fh6kiCsvR$*M;^Vs%@b;h}yr<7DWA9Xak~b7J+%yi#$NQql>IT z6w_ii5aqQPEZ4c%0mM7K*bYQ-FL4A>eoMe+_Al`Q@oq110a1!e!AcUBf{mWP)E59) C1AWr~ delta 283 zcmaEJn(57HCY}IqW)=|!1_llWqmqq0=NOrcN;cnNln2v)nWlm1sVw45An|o48Zb9ofE = { tone: "error", text: "Vul zowel je e-mailadres als je wachtwoord in.", }, + "invalid-email": { + tone: "error", + text: "Gebruik een geldig e-mailadres.", + }, + "password-too-short": { + tone: "error", + text: "Gebruik een wachtwoord van minimaal 8 tekens.", + }, "signup-failed": { tone: "error", text: "Je account kon niet worden aangemaakt. Probeer het opnieuw.", diff --git a/lib/config/feature-flags.ts b/lib/config/feature-flags.ts new file mode 100644 index 0000000..79830ab --- /dev/null +++ b/lib/config/feature-flags.ts @@ -0,0 +1,3 @@ +export function isTestWizardEnabled() { + return process.env.NEXT_PUBLIC_ENABLE_TEST_WIZARD === "true"; +} diff --git a/lib/feedback/status-messages.ts b/lib/feedback/status-messages.ts new file mode 100644 index 0000000..bcf955d --- /dev/null +++ b/lib/feedback/status-messages.ts @@ -0,0 +1,105 @@ +import { getAuthNotice } from "@/lib/auth/messages"; + +export type StatusToastVariant = "success" | "info" | "warning" | "error"; + +export type StatusToast = { + variant: StatusToastVariant; + title?: string; + message: string; +}; + +const dashboardStatusToasts: Record = { + "onboarding-completed": { + variant: "success", + title: "Onboarding opgeslagen", + message: "Je basisinstellingen staan nu klaar.", + }, + "onboarding-skipped": { + variant: "info", + title: "Onboarding overgeslagen", + message: "Je kunt de onboarding later alsnog afronden vanuit het dashboard.", + }, + "test-wizard-completed": { + variant: "success", + title: "Test wizard afgerond", + message: "De generieke wizard-flow werkt nu vanaf het dashboard.", + }, +}; + +const settingsStatusToasts: Record = { + saved: { + variant: "success", + title: "Instellingen opgeslagen", + message: "Je voorkeuren zijn bijgewerkt.", + }, +}; + +const settingsErrorToasts: Record = { + "invalid-settings-input": { + variant: "error", + title: "Instellingen niet opgeslagen", + message: "Controleer je tijd, timezone en voorkeurvelden en probeer het opnieuw.", + }, +}; + +const onboardingErrorToasts: Record = { + "invalid-onboarding-input": { + variant: "error", + title: "Onboarding niet opgeslagen", + message: "Controleer je ingevoerde voorkeuren en probeer het opnieuw.", + }, +}; + +export function getDashboardStatusToast(status: string | null): StatusToast | null { + if (!status) { + return null; + } + + return dashboardStatusToasts[status] ?? null; +} + +export function getSettingsStatusToast( + error: string | null, + status: string | null, +): StatusToast | null { + if (error && settingsErrorToasts[error]) { + return settingsErrorToasts[error]; + } + + if (!status) { + return null; + } + + return settingsStatusToasts[status] ?? null; +} + +export function getOnboardingStatusToast( + error: string | null, + status: string | null, +): StatusToast | null { + if (error && onboardingErrorToasts[error]) { + return onboardingErrorToasts[error]; + } + + if (!status) { + return null; + } + + return null; +} + +export function getAuthStatusToast( + error: string | null, + status: string | null, +): StatusToast | null { + const notice = getAuthNotice(error, status); + + if (!notice) { + return null; + } + + return { + variant: notice.tone, + message: notice.text, + }; +} diff --git a/lib/feedback/toast.ts b/lib/feedback/toast.ts new file mode 100644 index 0000000..70aada0 --- /dev/null +++ b/lib/feedback/toast.ts @@ -0,0 +1,26 @@ +"use client"; + +import { toast } from "sonner"; +import type { StatusToast } from "@/lib/feedback/status-messages"; + +export function showStatusToast(statusToast: StatusToast) { + const description = statusToast.title ? statusToast.message : undefined; + const message = statusToast.title ?? statusToast.message; + + if (statusToast.variant === "success") { + toast.success(message, { description }); + return; + } + + if (statusToast.variant === "info") { + toast.info(message, { description }); + return; + } + + if (statusToast.variant === "warning") { + toast.warning(message, { description }); + return; + } + + toast.error(message, { description }); +} diff --git a/lib/forms/parse.ts b/lib/forms/parse.ts new file mode 100644 index 0000000..82faaa7 --- /dev/null +++ b/lib/forms/parse.ts @@ -0,0 +1,117 @@ +const TIME_VALUE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; +const EMAIL_VALUE_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export class FormDataValidationError extends Error { + code: string; + + constructor(code: string) { + super(code); + this.code = code; + this.name = "FormDataValidationError"; + } +} + +function fail(code: string): never { + throw new FormDataValidationError(code); +} + +export function getOptionalString(formData: FormData, key: string): string | null { + const value = formData.get(key); + + if (typeof value !== "string") { + return null; + } + + return value.trim(); +} + +export function getRequiredString( + formData: FormData, + key: string, + errorCode: string, +): string { + const value = formData.get(key); + + if (typeof value !== "string") { + fail(errorCode); + } + + const trimmedValue = value.trim(); + + if (!trimmedValue) { + fail(errorCode); + } + + return trimmedValue; +} + +export function getBooleanValue( + formData: FormData, + key: string, + errorCode: string, +): boolean { + const value = formData.get(key); + + if (value === "true") { + return true; + } + + if (value === "false") { + return false; + } + + fail(errorCode); +} + +export function getEnumValue( + formData: FormData, + key: string, + allowedValues: readonly TValue[], + errorCode: string, +): TValue { + const value = getRequiredString(formData, key, errorCode); + + if (!allowedValues.includes(value as TValue)) { + fail(errorCode); + } + + return value as TValue; +} + +export function getOptionalTimeValue( + formData: FormData, + key: string, + errorCode: string, +): string | null { + const value = getOptionalString(formData, key); + + if (!value) { + return null; + } + + if (!TIME_VALUE_PATTERN.test(value)) { + fail(errorCode); + } + + return value; +} + +export function assertEmail(value: string, errorCode: string): string { + if (!EMAIL_VALUE_PATTERN.test(value)) { + fail(errorCode); + } + + return value; +} + +export function assertMinLength( + value: string, + minimumLength: number, + errorCode: string, +): string { + if (value.length < minimumLength) { + fail(errorCode); + } + + return value; +} diff --git a/lib/onboarding/use-onboarding-draft.ts b/lib/onboarding/use-onboarding-draft.ts new file mode 100644 index 0000000..aa1e1a3 --- /dev/null +++ b/lib/onboarding/use-onboarding-draft.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import { + type PreferenceDraft, + usePreferenceDraft, +} from "@/lib/preferences/use-preferences-draft"; +import type { ProfileBundle } from "@/lib/profile/types"; + +export type OnboardingDraft = PreferenceDraft & { + displayName: string; +}; + +export function useOnboardingDraft(profileBundle: ProfileBundle) { + const { draft: preferenceDraft, updateDraft: updatePreferenceDraft } = + usePreferenceDraft(profileBundle); + const [displayName, setDisplayName] = useState( + profileBundle.profile.displayName ?? "", + ); + + function updateDraft(patch: Partial) { + const { displayName: nextDisplayName, ...preferencePatch } = patch; + + if (nextDisplayName !== undefined) { + setDisplayName(nextDisplayName); + } + + if (Object.keys(preferencePatch).length > 0) { + updatePreferenceDraft(preferencePatch as Partial); + } + } + + return { + draft: { + displayName, + ...preferenceDraft, + }, + updateDraft, + }; +} diff --git a/lib/preferences/use-preferences-draft.ts b/lib/preferences/use-preferences-draft.ts new file mode 100644 index 0000000..2cd77f1 --- /dev/null +++ b/lib/preferences/use-preferences-draft.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import type { ProfileBundle } from "@/lib/profile/types"; + +export const DEFAULT_MORNING_REMINDER_TIME = "08:30"; + +export type PreferenceDraft = { + timezone: string; + showEnergyPoints: boolean; + morningReminderEnabled: boolean; + morningReminderTime: string; + reflectionReminderEnabled: boolean; +}; + +export function buildPreferenceDraft(profileBundle: ProfileBundle): PreferenceDraft { + return { + timezone: profileBundle.profile.timezone, + showEnergyPoints: profileBundle.settings.showEnergyPoints, + morningReminderEnabled: profileBundle.settings.morningReminderEnabled, + morningReminderTime: + profileBundle.settings.morningReminderTime ?? DEFAULT_MORNING_REMINDER_TIME, + reflectionReminderEnabled: profileBundle.settings.reflectionReminderEnabled, + }; +} + +export function usePreferenceDraft(profileBundle: ProfileBundle) { + const [draft, setDraft] = useState(() => + buildPreferenceDraft(profileBundle), + ); + + function updateDraft(patch: Partial) { + setDraft((currentDraft) => ({ ...currentDraft, ...patch })); + } + + return { + draft, + updateDraft, + }; +} diff --git a/lib/search-params.ts b/lib/search-params.ts new file mode 100644 index 0000000..12cc2e9 --- /dev/null +++ b/lib/search-params.ts @@ -0,0 +1,6 @@ +export type PageSearchParams = Record; + +export function getParamValue(params: PageSearchParams, key: string): string | null { + const value = params[key]; + return typeof value === "string" ? value : null; +} diff --git a/lib/wizard/types.ts b/lib/wizard/types.ts new file mode 100644 index 0000000..bcb3d65 --- /dev/null +++ b/lib/wizard/types.ts @@ -0,0 +1,19 @@ +export type WizardStepDefinition = { + id: string; + eyebrow?: string; + title: string; + description?: string; + canContinue?: (draft: TDraft) => boolean; +}; + +export type WizardFlowState = { + steps: WizardStepDefinition[]; + currentStepIndex: number; + currentStep: WizardStepDefinition; + isFirstStep: boolean; + isLastStep: boolean; + canContinue: boolean; + goToNextStep: () => void; + goToPreviousStep: () => void; + goToStep: (stepId: string) => void; +}; diff --git a/lib/wizard/use-wizard-flow.ts b/lib/wizard/use-wizard-flow.ts new file mode 100644 index 0000000..c122400 --- /dev/null +++ b/lib/wizard/use-wizard-flow.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { WizardFlowState, WizardStepDefinition } from "@/lib/wizard/types"; + +type UseWizardFlowOptions = { + steps: WizardStepDefinition[]; + draft: TDraft; + initialStepId?: string; +}; + +export function useWizardFlow({ + steps, + draft, + initialStepId, +}: UseWizardFlowOptions): WizardFlowState { + const initialStepIndex = useMemo(() => { + if (!initialStepId) { + return 0; + } + + const stepIndex = steps.findIndex((step) => step.id === initialStepId); + return stepIndex >= 0 ? stepIndex : 0; + }, [initialStepId, steps]); + + const [currentStepIndex, setCurrentStepIndex] = useState(initialStepIndex); + const currentStep = steps[currentStepIndex]; + const isFirstStep = currentStepIndex === 0; + const isLastStep = currentStepIndex === steps.length - 1; + const canContinue = currentStep?.canContinue ? currentStep.canContinue(draft) : true; + + function goToNextStep() { + if (isLastStep || !canContinue) { + return; + } + + setCurrentStepIndex((stepIndex) => Math.min(steps.length - 1, stepIndex + 1)); + } + + function goToPreviousStep() { + if (isFirstStep) { + return; + } + + setCurrentStepIndex((stepIndex) => Math.max(0, stepIndex - 1)); + } + + function goToStep(stepId: string) { + const stepIndex = steps.findIndex((step) => step.id === stepId); + + if (stepIndex >= 0) { + setCurrentStepIndex(stepIndex); + } + } + + return { + steps, + currentStepIndex, + currentStep, + isFirstStep, + isLastStep, + canContinue, + goToNextStep, + goToPreviousStep, + goToStep, + }; +} diff --git a/package-lock.json b/package-lock.json index a9c33d8..fc39f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,11 @@ "clsx": "^2.1.1", "lucide-react": "^1.8.0", "next": "16.2.0", + "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", "shadcn": "^4.3.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" }, @@ -7386,6 +7388,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -8815,6 +8827,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index de51b45..6a78528 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "clsx": "^2.1.1", "lucide-react": "^1.8.0", "next": "16.2.0", + "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", "shadcn": "^4.3.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" }, From f5b459dadb6a13849cc7564f18e7e5281af6c313 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 18 Apr 2026 18:42:31 +0200 Subject: [PATCH 02/31] Implement ST-201 morning check-in flow --- README.md | 7 +- app/check-in/actions.ts | 47 +++++ app/check-in/page.tsx | 135 +++++++++++++ app/dashboard/page.tsx | 5 + components/check-in/check-in-card.tsx | 56 ++++++ components/check-in/check-in-form.tsx | 178 ++++++++++++++++++ lib/check-in/options.ts | 21 +++ lib/check-in/service.ts | 146 ++++++++++++++ lib/check-in/types.ts | 22 +++ lib/feedback/status-messages.ts | 28 +++ lib/forms/parse.ts | 26 +++ .../20260418_create_morning_check_ins.sql | 42 +++++ 12 files changed, 710 insertions(+), 3 deletions(-) create mode 100644 app/check-in/actions.ts create mode 100644 app/check-in/page.tsx create mode 100644 components/check-in/check-in-card.tsx create mode 100644 components/check-in/check-in-form.tsx create mode 100644 lib/check-in/options.ts create mode 100644 lib/check-in/service.ts create mode 100644 lib/check-in/types.ts create mode 100644 supabase/migrations/20260418_create_morning_check_ins.sql diff --git a/README.md b/README.md index 3f99b90..e809929 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - e-mail/wachtwoord-auth via Supabase - protected dashboard met server-side sessiecontrole +- ochtendcheck-in voor energiescore en slaapkwaliteit van vandaag - korte onboardingflow voor eerste voorkeuren - instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten - `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen @@ -101,7 +102,7 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## Eerstvolgende bouwstappen -1. `ST-201` Ochtendcheck-in UI bouwen -2. `ST-203` Budgetlogica implementeren -3. `ST-301` Activiteitenmodel en planning opzetten +1. `ST-203` Budgetlogica implementeren +2. `ST-301` Activiteitenmodel en planning opzetten +3. `ST-401` Evaluatie- en dagoverzichtslus bouwen 4. `ST-105` RLS-policy tests en hardening afronden diff --git a/app/check-in/actions.ts b/app/check-in/actions.ts new file mode 100644 index 0000000..6e21b7b --- /dev/null +++ b/app/check-in/actions.ts @@ -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 { + 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; +} diff --git a/app/check-in/page.tsx b/app/check-in/page.tsx new file mode 100644 index 0000000..484352b --- /dev/null +++ b/app/check-in/page.tsx @@ -0,0 +1,135 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { signOutAction } from "@/app/auth-actions"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; +import { CheckInForm } from "@/components/check-in/check-in-form"; +import { Button, buttonVariants } from "@/components/ui/button"; +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 { getCheckInStatusToast } from "@/lib/feedback/status-messages"; +import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; +import { cn } from "@/lib/utils"; + +export const dynamic = "force-dynamic"; + +type CheckInPageProps = { + searchParams: Promise; +}; + +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 ( +
+
+ + +
+
+
+ + Dashboard + + / + Ochtendcheck-in +
+

+ Ochtendcheck-in van vandaag +

+

+ Houd je start rustig en klein. Je legt alleen een energiescore en een + globale slaapindruk vast voor vandaag. +

+
+ +
+ + Terug naar dashboard + +
+ +
+
+
+ +
+ + + +
+
+
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index dd53b1f..b1071c5 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { signOutAction } from "@/app/auth-actions"; +import { CheckInCard } from "@/components/check-in/check-in-card"; import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -12,6 +13,7 @@ import { } from "@/components/ui/card"; import { sanitizeNextPath } from "@/lib/auth/navigation"; 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 { getProfileBundleForCurrentUser } from "@/lib/profile/service"; @@ -51,6 +53,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps } const { profile, settings } = profileBundle; + const checkInStatus = await getTodayCheckInForCurrentUser(); const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status")); if (!profile.onboardingSeen) { @@ -174,6 +177,8 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps + + {isTestWizardEnabled() ? ( diff --git a/components/check-in/check-in-card.tsx b/components/check-in/check-in-card.tsx new file mode 100644 index 0000000..b815b59 --- /dev/null +++ b/components/check-in/check-in-card.tsx @@ -0,0 +1,56 @@ +import Link from "next/link"; +import { buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +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()}.` + : "Leg je energiestart en slaapkwaliteit van vandaag vast."; + + return ( + + +

+ Ochtendcheck-in +

+ {title} +
+ + + {description} + + + {todayCheckIn ? "Werk check-in bij" : "Start check-in"} + + +
+ ); +} diff --git a/components/check-in/check-in-form.tsx b/components/check-in/check-in-form.tsx new file mode 100644 index 0000000..c27d1f6 --- /dev/null +++ b/components/check-in/check-in-form.tsx @@ -0,0 +1,178 @@ +"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 { 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( + todayCheckIn?.energyScore ?? null, + ); + const [sleepQuality, setSleepQuality] = useState( + todayCheckIn?.sleepQuality ?? null, + ); + + return ( +
+ + + + + +

+ Ochtendcheck-in +

+ + Hoe start je vandaag? + + + Houd deze check-in klein. Je legt alleen vast hoe je energie en slaap + vandaag voelen, zodat de volgende stories daarop kunnen voortbouwen. + +
+ +
+
+ +

+ {getEnergyScorePrompt(energyScore)} +

+
+ +
+ {ENERGY_SCORE_VALUES.map((value) => { + const isSelected = energyScore === value; + + return ( + + ); + })} +
+
+ + + +
+
+ +

+ Eén globale indruk is genoeg voor deze eerste release. +

+
+ +
+ {SLEEP_QUALITY_OPTIONS.map((option) => { + const isSelected = sleepQuality === option.value; + + return ( + + ); + })} +
+
+
+
+ +
+

+ {isPending + ? "Je ochtendcheck-in wordt opgeslagen..." + : todayCheckIn + ? "Je kunt de check-in van vandaag nog aanpassen." + : "Je vult voor vandaag één check-in in, die je later nog kunt aanpassen."} +

+ + +
+
+ ); +} diff --git a/lib/check-in/options.ts b/lib/check-in/options.ts new file mode 100644 index 0000000..c11d9fd --- /dev/null +++ b/lib/check-in/options.ts @@ -0,0 +1,21 @@ +export const SLEEP_QUALITY_OPTIONS = [ + { + value: "goed", + label: "Goed", + description: "Je bent redelijk uitgerust wakker geworden.", + }, + { + value: "matig", + label: "Matig", + description: "Je slaap was onrustig of niet helemaal herstellend.", + }, + { + value: "slecht", + label: "Slecht", + description: "Je voelt dat je slaap duidelijk onvoldoende hielp.", + }, +] as const; + +export const SLEEP_QUALITY_VALUES = SLEEP_QUALITY_OPTIONS.map((option) => option.value); + +export const ENERGY_SCORE_VALUES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const; diff --git a/lib/check-in/service.ts b/lib/check-in/service.ts new file mode 100644 index 0000000..99c7489 --- /dev/null +++ b/lib/check-in/service.ts @@ -0,0 +1,146 @@ +import { getAuthenticatedUser } from "@/lib/auth/session"; +import { ensureProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { createClient } from "@/lib/supabase/server"; +import type { + MorningCheckInRecord, + MorningCheckInStatus, + MorningCheckInSubmission, + SleepQuality, +} from "@/lib/check-in/types"; + +type SupabaseServerClient = Awaited>; + +type MorningCheckInRow = { + id: string; + user_id: string; + check_in_date: string; + energy_score: number; + sleep_quality: SleepQuality; + created_at: string; + updated_at: string; +}; + +type MorningCheckInInsert = { + user_id: string; + check_in_date: string; + energy_score: number; + sleep_quality: SleepQuality; +}; + +const MORNING_CHECK_IN_COLUMNS = + "id, user_id, check_in_date, energy_score, sleep_quality, created_at, updated_at"; + +function mapMorningCheckInRow(row: MorningCheckInRow): MorningCheckInRecord { + return { + id: row.id, + userId: row.user_id, + checkInDate: row.check_in_date, + energyScore: row.energy_score, + sleepQuality: row.sleep_quality, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function getLocalDateForTimezone(timezone: string, date = new Date()) { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + + const parts = formatter.formatToParts(date); + const year = parts.find((part) => part.type === "year")?.value; + const month = parts.find((part) => part.type === "month")?.value; + const day = parts.find((part) => part.type === "day")?.value; + + if (!year || !month || !day) { + throw new Error("Lokale datum voor timezone kon niet worden bepaald."); + } + + return `${year}-${month}-${day}`; +} + +async function readMorningCheckInByDate( + supabase: SupabaseServerClient, + userId: string, + checkInDate: string, +): Promise { + const { data, error } = await supabase + .from("morning_check_ins") + .select(MORNING_CHECK_IN_COLUMNS) + .eq("user_id", userId) + .eq("check_in_date", checkInDate) + .maybeSingle(); + + if (error) { + throw new Error(`Ochtendcheck-in kon niet worden geladen: ${error.message}`); + } + + return data; +} + +export async function getTodayCheckInForCurrentUser(): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + return null; + } + + const profileBundle = await ensureProfileBundleForCurrentUser(); + + if (!profileBundle) { + return null; + } + + const timezone = profileBundle.profile.timezone; + const todayDate = getLocalDateForTimezone(timezone); + const supabase = await createClient(); + const morningCheckInRow = await readMorningCheckInByDate(supabase, user.id, todayDate); + + return { + timezone, + todayDate, + todayCheckIn: morningCheckInRow ? mapMorningCheckInRow(morningCheckInRow) : null, + }; +} + +export async function upsertTodayCheckInForCurrentUser( + submission: MorningCheckInSubmission, +): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + throw new Error("Er is geen ingelogde gebruiker beschikbaar."); + } + + const profileBundle = await ensureProfileBundleForCurrentUser(); + + if (!profileBundle) { + throw new Error("Profielbundle ontbreekt voor de huidige gebruiker."); + } + + const checkInDate = getLocalDateForTimezone(profileBundle.profile.timezone); + const payload: MorningCheckInInsert = { + user_id: user.id, + check_in_date: checkInDate, + energy_score: submission.energyScore, + sleep_quality: submission.sleepQuality, + }; + + const supabase = await createClient(); + const { data, error } = await supabase + .from("morning_check_ins") + .upsert(payload, { + onConflict: "user_id,check_in_date", + }) + .select(MORNING_CHECK_IN_COLUMNS) + .single(); + + if (error) { + throw new Error(`Ochtendcheck-in kon niet worden opgeslagen: ${error.message}`); + } + + return mapMorningCheckInRow(data); +} diff --git a/lib/check-in/types.ts b/lib/check-in/types.ts new file mode 100644 index 0000000..10f6b56 --- /dev/null +++ b/lib/check-in/types.ts @@ -0,0 +1,22 @@ +export type SleepQuality = "goed" | "matig" | "slecht"; + +export type MorningCheckInRecord = { + id: string; + userId: string; + checkInDate: string; + energyScore: number; + sleepQuality: SleepQuality; + createdAt: string; + updatedAt: string; +}; + +export type MorningCheckInSubmission = { + energyScore: number; + sleepQuality: SleepQuality; +}; + +export type MorningCheckInStatus = { + timezone: string; + todayDate: string; + todayCheckIn: MorningCheckInRecord | null; +}; diff --git a/lib/feedback/status-messages.ts b/lib/feedback/status-messages.ts index bcf955d..5bcd8ab 100644 --- a/lib/feedback/status-messages.ts +++ b/lib/feedback/status-messages.ts @@ -24,6 +24,11 @@ const dashboardStatusToasts: Record = { title: "Test wizard afgerond", message: "De generieke wizard-flow werkt nu vanaf het dashboard.", }, + "check-in-saved": { + variant: "success", + title: "Ochtendcheck-in opgeslagen", + message: "Je energiestart van vandaag staat nu klaar op je dashboard.", + }, }; const settingsStatusToasts: Record = { @@ -50,6 +55,14 @@ const onboardingErrorToasts: Record = { }, }; +const checkInErrorToasts: Record = { + "invalid-check-in-input": { + variant: "error", + title: "Check-in niet opgeslagen", + message: "Kies een energiescore tussen 1 en 10 en een geldige slaapkwaliteit.", + }, +}; + export function getDashboardStatusToast(status: string | null): StatusToast | null { if (!status) { return null; @@ -88,6 +101,21 @@ export function getOnboardingStatusToast( return null; } +export function getCheckInStatusToast( + error: string | null, + status: string | null, +): StatusToast | null { + if (error && checkInErrorToasts[error]) { + return checkInErrorToasts[error]; + } + + if (!status) { + return null; + } + + return null; +} + export function getAuthStatusToast( error: string | null, status: string | null, diff --git a/lib/forms/parse.ts b/lib/forms/parse.ts index 82faaa7..81c40d1 100644 --- a/lib/forms/parse.ts +++ b/lib/forms/parse.ts @@ -1,5 +1,6 @@ const TIME_VALUE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; const EMAIL_VALUE_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const INTEGER_VALUE_PATTERN = /^-?\d+$/; export class FormDataValidationError extends Error { code: string; @@ -115,3 +116,28 @@ export function assertMinLength( return value; } + +export function getIntegerValue( + formData: FormData, + key: string, + range: { min: number; max: number }, + errorCode: string, +): number { + const value = getRequiredString(formData, key, errorCode); + + if (!INTEGER_VALUE_PATTERN.test(value)) { + fail(errorCode); + } + + const parsedValue = Number.parseInt(value, 10); + + if ( + Number.isNaN(parsedValue) || + parsedValue < range.min || + parsedValue > range.max + ) { + fail(errorCode); + } + + return parsedValue; +} diff --git a/supabase/migrations/20260418_create_morning_check_ins.sql b/supabase/migrations/20260418_create_morning_check_ins.sql new file mode 100644 index 0000000..b35a2b1 --- /dev/null +++ b/supabase/migrations/20260418_create_morning_check_ins.sql @@ -0,0 +1,42 @@ +create table if not exists public.morning_check_ins ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + check_in_date date not null, + energy_score integer not null check (energy_score between 1 and 10), + sleep_quality text not null check (sleep_quality in ('goed', 'matig', 'slecht')), + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + unique (user_id, check_in_date) +); + +grant select, insert, update on table public.morning_check_ins to authenticated; + +alter table public.morning_check_ins enable row level security; + +drop trigger if exists set_morning_check_ins_updated_at on public.morning_check_ins; +create trigger set_morning_check_ins_updated_at +before update on public.morning_check_ins +for each row +execute function public.set_updated_at(); + +drop policy if exists "morning_check_ins_select_own" on public.morning_check_ins; +create policy "morning_check_ins_select_own" +on public.morning_check_ins +for select +to authenticated +using ((select auth.uid()) = user_id); + +drop policy if exists "morning_check_ins_insert_own" on public.morning_check_ins; +create policy "morning_check_ins_insert_own" +on public.morning_check_ins +for insert +to authenticated +with check ((select auth.uid()) = user_id); + +drop policy if exists "morning_check_ins_update_own" on public.morning_check_ins; +create policy "morning_check_ins_update_own" +on public.morning_check_ins +for update +to authenticated +using ((select auth.uid()) = user_id) +with check ((select auth.uid()) = user_id); From 899c1af8249dd95483b75b94b229903d7c31392e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 18 Apr 2026 18:50:27 +0200 Subject: [PATCH 03/31] Add app icon assets and project notes --- CLAUDE.md | 84 +++ aanbeveling-claude.md | 136 ++++ app/apple-icon.png | Bin 0 -> 6060 bytes app/icon.svg | 27 + docs/generate_testplan.py | 694 ++++++++++++++++++ docs/icon-concepts/README.md | 29 + docs/icon-concepts/concept-01-open-ring.svg | 16 + .../icon-concepts/concept-02-double-orbit.svg | 27 + .../icon-concepts/concept-03-horizon-ring.svg | 27 + docs/inspannings-monitor-07-testplan-v01.docx | Bin 0 -> 49777 bytes 10 files changed, 1040 insertions(+) create mode 100644 CLAUDE.md create mode 100644 aanbeveling-claude.md create mode 100644 app/apple-icon.png create mode 100644 app/icon.svg create mode 100644 docs/generate_testplan.py create mode 100644 docs/icon-concepts/README.md create mode 100644 docs/icon-concepts/concept-01-open-ring.svg create mode 100644 docs/icon-concepts/concept-02-double-orbit.svg create mode 100644 docs/icon-concepts/concept-03-horizon-ring.svg create mode 100644 docs/inspannings-monitor-07-testplan-v01.docx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c200e35 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/aanbeveling-claude.md b/aanbeveling-claude.md new file mode 100644 index 0000000..6ecd05b --- /dev/null +++ b/aanbeveling-claude.md @@ -0,0 +1,136 @@ +# Aanbevelingen voor Inspannings Monitor + +Dit bestand bevat aanbevelingen gebaseerd op analyse van de broncode, documentatie en backlog (peildatum: 2026-04-18). Bedoeld als leidraad voor toekomstige werksessies. + +--- + +## Huidige status + +De volgende onderdelen zijn volledig geïmplementeerd en van goede kwaliteit: + +| Onderdeel | Status | +|---|---| +| Authenticatie (login, signup, e-mailbevestiging, uitloggen) | ✅ Af | +| Beveiligde routes met server-side sessiecontrole | ✅ Af | +| Onboarding flow (3 stappen, profiel- en instellingenopslag) | ✅ Af | +| Instellingenbeheer (tijdzone, herinneringen, energiepunten) | ✅ Af | +| Databaseschema met RLS-beleid | ✅ Af | +| CI/CD (GitHub Actions + Vercel) | ✅ Af | +| Branchbeveiliging op `main` | ✅ Af | + +--- + +## Volgende prioriteiten (volgorde uit backlog) + +### 1. ST-201 — Ochtendcheck-in UI (P0, EPIC-03) +Het hart van de applicatie. Zonder check-in heeft het dashboard geen inhoud. Bouwen als: +- Energieschuifregelaar (1–10) +- Slaapkwaliteitsinput (goed / matig / slecht) +- Opslaan in nieuwe tabel `morning_check_ins` +- Check-in status tonen op het dashboard (al ingevoerd of nog niet) + +### 2. ST-203 — Budgetberekening (P0, EPIC-03) +Zodra de check-in werkt, berekent de app automatisch het dagbudget op basis van de energiescore. Vereist: +- Score-naar-budget mapping (formule vastleggen vóór implementatie) +- Edge cases: score = 1, score = 10, geen check-in +- Eenheidstests voor de berekeningslogica — dit is de enige plek in het project waar tests nu echt urgent zijn + +### 3. ST-301 t/m ST-305 — Dagplanning en energiemeter (P0, EPIC-04) +- Activiteiteninvoer met categorie, duur en energiepuntschatting +- Lopende energiemeter (resterend budget) +- Waarschuwing bij overschrijding (niet-blokkerend) +- Vereist nieuwe tabellen: `activities`, `activity_instances`, `activity_categories` + +### 4. ST-401 t/m ST-405 — Evaluatie en dagoverzicht (P0, EPIC-05) +- Activiteiten afvinken als voltooid, overgeslagen of aangepast +- Dagelijkse samenvatting: gepland vs. werkelijk +- Sluit de plan-do-evaluate-lus + +### 5. ST-105 — RLS hardening (P0, EPIC-08, parallel uitvoeren) +RLS-beleid is aangemaakt maar nog niet grondig getest. Voer dit parallel uit aan de feature-bouw: +- Testscripts schrijven die proberen om andermans rijen te lezen/schrijven +- Bevestigen dat `service_role`-key nergens in de frontend of Vercel-configuratie staat + +--- + +## Technische schuld + +De huidige code is van goede kwaliteit, maar bevat een aantal verbeterpunten die bij de volgende feature-bouwfase opgepakt kunnen worden — niet nu, maar vóór launch. + +### Matig urgent + +| Probleem | Bestand(en) | Oplossing | +|---|---|---| +| `getParamValue()` 4× gedupliceerd | `app/*/page.tsx` | Verplaats naar `lib/auth/params.ts` | +| `onboarding-flow.tsx` is 343 regels | `components/onboarding/onboarding-flow.tsx` | Splits in drie stapcomponenten | +| `settings-form.tsx` dupliceert toestandslogica van onboarding | `components/settings/settings-form.tsx` | Extraheer gedeelde hook | + +### Laag urgent (vóór launch) + +| Probleem | Bestand(en) | Oplossing | +|---|---|---| +| Geen laadstatus tijdens server actions | `onboarding-flow.tsx`, `settings-form.tsx` | Gebruik `useTransition` + pending-state | +| Geen toast/melding na formulieropslaan | Alle clientcomponenten | shadcn/ui `toast` toevoegen | +| Booleaanse extractie uit FormData stil faalbaar | `app/**/actions.ts` | Expliciete validatie toevoegen | + +--- + +## Risico's en aandachtspunten vóór launch + +### Security +- **Oud `service_role`-geheim in git-history**: Sleutel is als gecompromitteerd behandeld en niet meer in gebruik. Git-history opschonen is nog niet gedaan. Voer dit uit vóór publieke launch als extra voorzorgsmaatregel (`git filter-repo` of BFG Repo Cleaner). +- **RLS nog niet gehard (ST-105)**: Blokkeer launch totdat dit getest is. +- **Rate limiting ontbreekt (ST-701)**: Sign-up en sign-in eindpunten zijn momenteel niet beperkt op applicatieniveau (Supabase heeft eigen throttling, maar expliciete applicatielaag ontbreekt). + +### Privacy +- **DPIA nog niet gedaan (ST-803)**: Verplicht vóór launch omdat gezondheidsdata wordt verwerkt, ook al zijn het welzijnsgegevens. +- **Copyreview (ST-803)**: Alle teksten moeten door een copycheck — geen medische claims, geen diagnoses, geen therapeutisch advies. Dit is een harde launchpoort. +- **Gegevensretentie**: Nog geen beleid of implementatie voor het verwijderen van oude daggegevens. + +### Kwaliteit +- **Geen testinfrastructuur**: Er zijn geen tests. Minimaal de budgetberekening (ST-203) en RLS-beleid vereisen geautomatiseerde tests vóór launch. +- **Geen toegankelijkheidsaudit (ST-802)**: WCAG 2.1 AA is de norm; nog niet gecontroleerd. + +--- + +## Architectuuradvies voor de volgende bouwfase + +### Databaseschema uitbreiden +Voeg tabellen toe in deze volgorde (met migraties in `/supabase/migrations/`): +1. `morning_check_ins` — energiescore, slaapkwaliteit, berekend budget, datum +2. `activity_categories` — referentiedata (naam, standaard energiepunten) +3. `activities` — geplande activiteiten per dag per gebruiker +4. `activity_instances` — werkelijk uitgevoerd, overgeslagen of aangepast +5. `skip_reasons` — optionele referentiedata + +Alle tabellen krijgen RLS-beleid op `user_id = auth.uid()`. + +### Servicelaag uitbreiden (`lib/`) +Volg het bestaande patroon in `lib/profile/service.ts`: +- Maak `lib/checkin/service.ts` voor ochtendcheck-in logica +- Maak `lib/planning/service.ts` voor activiteitenbeheer +- Houd serveracties (`app/**/actions.ts`) dun — valideren, delegeren naar service, redirecten + +### Componentstrategie +- Gebruik het bestaande shadcn/ui-fundament (`components/ui/`) +- Voeg geen nieuwe UI-bibliotheek toe +- Bouw feature-componenten in eigen mappen (`components/checkin/`, `components/planning/`, `components/evaluation/`) +- Voeg `useTransition` toe zodra een form meer dan één server roundtrip kost + +### Wanneer testen toevoegen +- Start met tests bij ST-203 (budgetberekening) — pure functie, makkelijk te testen +- Voeg RLS-integratietests toe bij ST-105 +- Gebruik Vitest (past bij de huidige toolchain, geen extra configuratie nodig naast het toevoegen van het pakket) + +--- + +## Samenvatting prioriteitsvolgorde + +``` +Nu: ST-201 (ochtendcheck-in UI) +Dan: ST-203 (budgetlogica + eerste tests) +Daarna: ST-301–305 (dagplanning) +Daarna: ST-401–405 (evaluatie) +Parallel: ST-105 (RLS), ST-701 (rate limiting) +Vóór launch: ST-802 (toegankelijkheid), ST-803 (copy + DPIA) +``` diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8da98069fe713a9aefb98143f9c1209530d201a5 GIT binary patch literal 6060 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 X7IvMD;U1UxolH;5ShM!NBl`aUibJ_4 literal 0 HcmV?d00001 diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..579db7a --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,27 @@ + + + + + + + diff --git a/docs/generate_testplan.py b/docs/generate_testplan.py new file mode 100644 index 0000000..cf0890b --- /dev/null +++ b/docs/generate_testplan.py @@ -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() diff --git a/docs/icon-concepts/README.md b/docs/icon-concepts/README.md new file mode 100644 index 0000000..8204681 --- /dev/null +++ b/docs/icon-concepts/README.md @@ -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. diff --git a/docs/icon-concepts/concept-01-open-ring.svg b/docs/icon-concepts/concept-01-open-ring.svg new file mode 100644 index 0000000..65c02cb --- /dev/null +++ b/docs/icon-concepts/concept-01-open-ring.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/docs/icon-concepts/concept-02-double-orbit.svg b/docs/icon-concepts/concept-02-double-orbit.svg new file mode 100644 index 0000000..8bcce74 --- /dev/null +++ b/docs/icon-concepts/concept-02-double-orbit.svg @@ -0,0 +1,27 @@ + + + + + + + diff --git a/docs/icon-concepts/concept-03-horizon-ring.svg b/docs/icon-concepts/concept-03-horizon-ring.svg new file mode 100644 index 0000000..9afa78a --- /dev/null +++ b/docs/icon-concepts/concept-03-horizon-ring.svg @@ -0,0 +1,27 @@ + + + + + + + diff --git a/docs/inspannings-monitor-07-testplan-v01.docx b/docs/inspannings-monitor-07-testplan-v01.docx new file mode 100644 index 0000000000000000000000000000000000000000..52cdeadc1b4bd347f7b358f9ce939f1e302bd39b GIT binary patch literal 49777 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 literal 0 HcmV?d00001 From 2c9344a94fcba98b3294e1dac597c1fca4af6770 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 18 Apr 2026 18:53:45 +0200 Subject: [PATCH 04/31] Add favicon asset --- app/favicon.ico | Bin 0 -> 6412 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/favicon.ico diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..01beb8807f7ed9a6fb814063441f3cbe99bbd9df GIT binary patch literal 6412 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 literal 0 HcmV?d00001 From 07afbc32135f4c61b6c76219c3d0109d3185a18d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 18 Apr 2026 19:00:24 +0200 Subject: [PATCH 05/31] Implement ST-203 budget logic and tests --- README.md | 1 + app/check-in/page.tsx | 14 +- components/check-in/check-in-card.tsx | 3 +- components/check-in/check-in-form.tsx | 13 +- lib/check-in/budget.test.ts | 60 ++ lib/check-in/budget.ts | 56 ++ lib/check-in/service.ts | 17 +- lib/check-in/types.ts | 4 + package-lock.json | 848 +++++++++++++++++- package.json | 6 +- ...add_budget_fields_to_morning_check_ins.sql | 44 + 11 files changed, 1057 insertions(+), 9 deletions(-) create mode 100644 lib/check-in/budget.test.ts create mode 100644 lib/check-in/budget.ts create mode 100644 supabase/migrations/20260418_add_budget_fields_to_morning_check_ins.sql diff --git a/README.md b/README.md index e809929..07656c3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - e-mail/wachtwoord-auth via Supabase - protected dashboard met server-side sessiecontrole - ochtendcheck-in voor energiescore en slaapkwaliteit van vandaag +- eenvoudig dagbudget en energieniveau op basis van de ochtendscore - korte onboardingflow voor eerste voorkeuren - instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten - `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen diff --git a/app/check-in/page.tsx b/app/check-in/page.tsx index 484352b..ac869ce 100644 --- a/app/check-in/page.tsx +++ b/app/check-in/page.tsx @@ -13,6 +13,7 @@ import { } 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"; @@ -108,10 +109,17 @@ export default async function CheckInPage({ searchParams }: CheckInPageProps) {
- - Lokale datum: {checkInStatus?.todayDate ?? "Onbekend"} in timezone{" "} + + Lokale datum: {checkInStatus?.todayDate ?? "Onbekend"} in timezone{" "} `{profileBundle.profile.timezone}`. + {checkInStatus?.todayCheckIn ? ( + + Laatste resultaat: niveau{" "} + {formatEnergyLevelLabel(checkInStatus.todayCheckIn.energyLevel).toLowerCase()} met een budget van{" "} + {checkInStatus.todayCheckIn.dailyBudget} punten. + + ) : null}
@@ -124,7 +132,7 @@ export default async function CheckInPage({ searchParams }: CheckInPageProps) {

Deze check-in geeft geen diagnose of medische interpretatie.

Je legt alleen een rustige momentopname van vandaag vast.

-

De budgetlogica volgt pas in de volgende story.

+

Budget v1 blijft bewust eenvoudig: het dagbudget volgt direct uit je energiescore.

diff --git a/components/check-in/check-in-card.tsx b/components/check-in/check-in-card.tsx index b815b59..c8ffb2b 100644 --- a/components/check-in/check-in-card.tsx +++ b/components/check-in/check-in-card.tsx @@ -7,6 +7,7 @@ import { 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"; @@ -29,7 +30,7 @@ function formatSleepQualityLabel(value: MorningCheckInRecord["sleepQuality"]) { 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()}.` + ? `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 ( diff --git a/components/check-in/check-in-form.tsx b/components/check-in/check-in-form.tsx index c27d1f6..a5ece0a 100644 --- a/components/check-in/check-in-form.tsx +++ b/components/check-in/check-in-form.tsx @@ -12,6 +12,10 @@ import { } 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, @@ -47,6 +51,7 @@ export function CheckInForm({ todayCheckIn }: CheckInFormProps) { const [sleepQuality, setSleepQuality] = useState( todayCheckIn?.sleepQuality ?? null, ); + const predictedBudget = energyScore === null ? null : deriveBudgetSnapshot(energyScore); return (
@@ -75,6 +80,12 @@ export function CheckInForm({ todayCheckIn }: CheckInFormProps) {

{getEnergyScorePrompt(energyScore)}

+ {predictedBudget ? ( +

+ Voor vandaag geeft dit niveau {formatEnergyLevelLabel(predictedBudget.energyLevel).toLowerCase()} en een startbudget van{" "} + {predictedBudget.dailyBudget} punten. +

+ ) : null}
@@ -156,7 +167,7 @@ export function CheckInForm({ todayCheckIn }: CheckInFormProps) { {isPending ? "Je ochtendcheck-in wordt opgeslagen..." : todayCheckIn - ? "Je kunt de check-in van vandaag nog aanpassen." + ? "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."}

diff --git a/lib/check-in/budget.test.ts b/lib/check-in/budget.test.ts new file mode 100644 index 0000000..c61e36a --- /dev/null +++ b/lib/check-in/budget.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + BUDGET_FORMULA_VERSION, + deriveBudgetSnapshot, + deriveDailyBudget, + deriveEnergyLevel, + formatEnergyLevelLabel, +} from "./budget"; + +describe("deriveEnergyLevel", () => { + it("maps scores 1 and 2 to zeer_laag", () => { + expect(deriveEnergyLevel(1)).toBe("zeer_laag"); + expect(deriveEnergyLevel(2)).toBe("zeer_laag"); + }); + + it("maps scores 3 and 4 to laag", () => { + expect(deriveEnergyLevel(3)).toBe("laag"); + expect(deriveEnergyLevel(4)).toBe("laag"); + }); + + it("maps scores 5 and 6 to midden", () => { + expect(deriveEnergyLevel(5)).toBe("midden"); + expect(deriveEnergyLevel(6)).toBe("midden"); + }); + + it("maps scores 7 and 8 to redelijk", () => { + expect(deriveEnergyLevel(7)).toBe("redelijk"); + expect(deriveEnergyLevel(8)).toBe("redelijk"); + }); + + it("maps scores 9 and 10 to hoog", () => { + expect(deriveEnergyLevel(9)).toBe("hoog"); + expect(deriveEnergyLevel(10)).toBe("hoog"); + }); +}); + +describe("deriveDailyBudget", () => { + it("keeps v1 daily budget equal to the energy score", () => { + expect(deriveDailyBudget(1)).toBe(1); + expect(deriveDailyBudget(5)).toBe(5); + expect(deriveDailyBudget(10)).toBe(10); + }); +}); + +describe("deriveBudgetSnapshot", () => { + it("returns a stable snapshot with formula version", () => { + expect(deriveBudgetSnapshot(6)).toEqual({ + energyLevel: "midden", + dailyBudget: 6, + budgetFormulaVersion: BUDGET_FORMULA_VERSION, + }); + }); +}); + +describe("formatEnergyLevelLabel", () => { + it("returns readable Dutch labels", () => { + expect(formatEnergyLevelLabel("zeer_laag")).toBe("Zeer laag"); + expect(formatEnergyLevelLabel("redelijk")).toBe("Redelijk"); + }); +}); diff --git a/lib/check-in/budget.ts b/lib/check-in/budget.ts new file mode 100644 index 0000000..42a4d8f --- /dev/null +++ b/lib/check-in/budget.ts @@ -0,0 +1,56 @@ +import type { EnergyLevel } from "@/lib/check-in/types"; + +export const BUDGET_FORMULA_VERSION = 1; + +export type BudgetSnapshot = { + energyLevel: EnergyLevel; + dailyBudget: number; + budgetFormulaVersion: number; +}; + +export function deriveEnergyLevel(energyScore: number): EnergyLevel { + if (energyScore <= 2) { + return "zeer_laag"; + } + + if (energyScore <= 4) { + return "laag"; + } + + if (energyScore <= 6) { + return "midden"; + } + + if (energyScore <= 8) { + return "redelijk"; + } + + return "hoog"; +} + +export function deriveDailyBudget(energyScore: number): number { + return energyScore; +} + +export function deriveBudgetSnapshot(energyScore: number): BudgetSnapshot { + return { + energyLevel: deriveEnergyLevel(energyScore), + dailyBudget: deriveDailyBudget(energyScore), + budgetFormulaVersion: BUDGET_FORMULA_VERSION, + }; +} + +export function formatEnergyLevelLabel(energyLevel: EnergyLevel): string { + switch (energyLevel) { + case "zeer_laag": + return "Zeer laag"; + case "laag": + return "Laag"; + case "midden": + return "Midden"; + case "redelijk": + return "Redelijk"; + case "hoog": + return "Hoog"; + } +} diff --git a/lib/check-in/service.ts b/lib/check-in/service.ts index 99c7489..4fda3aa 100644 --- a/lib/check-in/service.ts +++ b/lib/check-in/service.ts @@ -1,7 +1,9 @@ import { getAuthenticatedUser } from "@/lib/auth/session"; +import { deriveBudgetSnapshot } from "@/lib/check-in/budget"; import { ensureProfileBundleForCurrentUser } from "@/lib/profile/service"; import { createClient } from "@/lib/supabase/server"; import type { + EnergyLevel, MorningCheckInRecord, MorningCheckInStatus, MorningCheckInSubmission, @@ -16,6 +18,9 @@ type MorningCheckInRow = { check_in_date: string; energy_score: number; sleep_quality: SleepQuality; + energy_level: EnergyLevel; + daily_budget: number; + budget_formula_version: number; created_at: string; updated_at: string; }; @@ -25,10 +30,13 @@ type MorningCheckInInsert = { check_in_date: string; energy_score: number; sleep_quality: SleepQuality; + energy_level: EnergyLevel; + daily_budget: number; + budget_formula_version: number; }; const MORNING_CHECK_IN_COLUMNS = - "id, user_id, check_in_date, energy_score, sleep_quality, created_at, updated_at"; + "id, user_id, check_in_date, energy_score, sleep_quality, energy_level, daily_budget, budget_formula_version, created_at, updated_at"; function mapMorningCheckInRow(row: MorningCheckInRow): MorningCheckInRecord { return { @@ -37,6 +45,9 @@ function mapMorningCheckInRow(row: MorningCheckInRow): MorningCheckInRecord { checkInDate: row.check_in_date, energyScore: row.energy_score, sleepQuality: row.sleep_quality, + energyLevel: row.energy_level, + dailyBudget: row.daily_budget, + budgetFormulaVersion: row.budget_formula_version, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -122,11 +133,15 @@ export async function upsertTodayCheckInForCurrentUser( } const checkInDate = getLocalDateForTimezone(profileBundle.profile.timezone); + const budgetSnapshot = deriveBudgetSnapshot(submission.energyScore); const payload: MorningCheckInInsert = { user_id: user.id, check_in_date: checkInDate, energy_score: submission.energyScore, sleep_quality: submission.sleepQuality, + energy_level: budgetSnapshot.energyLevel, + daily_budget: budgetSnapshot.dailyBudget, + budget_formula_version: budgetSnapshot.budgetFormulaVersion, }; const supabase = await createClient(); diff --git a/lib/check-in/types.ts b/lib/check-in/types.ts index 10f6b56..506e302 100644 --- a/lib/check-in/types.ts +++ b/lib/check-in/types.ts @@ -1,4 +1,5 @@ export type SleepQuality = "goed" | "matig" | "slecht"; +export type EnergyLevel = "zeer_laag" | "laag" | "midden" | "redelijk" | "hoog"; export type MorningCheckInRecord = { id: string; @@ -6,6 +7,9 @@ export type MorningCheckInRecord = { checkInDate: string; energyScore: number; sleepQuality: SleepQuality; + energyLevel: EnergyLevel; + dailyBudget: number; + budgetFormulaVersion: number; createdAt: string; updatedAt: string; }; diff --git a/package-lock.json b/package-lock.json index fc39f3d..63fcb42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,8 @@ "eslint-config-next": "16.2.0", "postcss": "latest", "tailwindcss": "latest", - "typescript": "latest" + "typescript": "latest", + "vitest": "^4.1.4" }, "engines": { "node": ">=20.9.0" @@ -2017,6 +2018,340 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2042,6 +2377,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.103.3", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.3.tgz", @@ -2518,6 +2860,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3186,6 +3546,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3490,6 +3963,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -3755,6 +4238,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4519,6 +5012,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5002,6 +5502,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5068,6 +5578,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5419,6 +5939,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7647,6 +8182,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -7901,6 +8447,13 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8319,6 +8872,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8809,6 +9396,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8862,6 +9456,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -8871,6 +9472,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -9203,6 +9811,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -9251,6 +9876,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", @@ -9696,6 +10331,200 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -9809,6 +10638,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 6a78528..706d99b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build --webpack", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run" }, "dependencies": { "@base-ui/react": "^1.4.0", @@ -33,7 +34,8 @@ "eslint-config-next": "16.2.0", "postcss": "latest", "tailwindcss": "latest", - "typescript": "latest" + "typescript": "latest", + "vitest": "^4.1.4" }, "engines": { "node": ">=20.9.0" diff --git a/supabase/migrations/20260418_add_budget_fields_to_morning_check_ins.sql b/supabase/migrations/20260418_add_budget_fields_to_morning_check_ins.sql new file mode 100644 index 0000000..795c17a --- /dev/null +++ b/supabase/migrations/20260418_add_budget_fields_to_morning_check_ins.sql @@ -0,0 +1,44 @@ +alter table public.morning_check_ins +add column if not exists energy_level text; + +alter table public.morning_check_ins +add column if not exists daily_budget integer; + +alter table public.morning_check_ins +add column if not exists budget_formula_version integer; + +update public.morning_check_ins +set + energy_level = case + when energy_score between 1 and 2 then 'zeer_laag' + when energy_score between 3 and 4 then 'laag' + when energy_score between 5 and 6 then 'midden' + when energy_score between 7 and 8 then 'redelijk' + else 'hoog' + end, + daily_budget = energy_score, + budget_formula_version = 1 +where energy_level is null + or daily_budget is null + or budget_formula_version is null; + +alter table public.morning_check_ins +alter column energy_level set not null; + +alter table public.morning_check_ins +alter column daily_budget set not null; + +alter table public.morning_check_ins +alter column budget_formula_version set not null; + +alter table public.morning_check_ins +add constraint morning_check_ins_energy_level_check +check (energy_level in ('zeer_laag', 'laag', 'midden', 'redelijk', 'hoog')); + +alter table public.morning_check_ins +add constraint morning_check_ins_daily_budget_check +check (daily_budget >= 1); + +alter table public.morning_check_ins +add constraint morning_check_ins_budget_formula_version_check +check (budget_formula_version >= 1); From 8864eb79664611a471129f50f5504d2c6e7a8563 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 18 Apr 2026 19:02:53 +0200 Subject: [PATCH 06/31] Update documentation for check-in and budget progress --- README.md | 14 ++++++++++---- docs/README.md | 8 ++++++++ docs/backlog/inspannings-monitor-backlog.md | 12 +++++++----- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 07656c3..f67d0c2 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - 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 +- eerste unit tests voor budgetmapping via `Vitest` - korte onboardingflow voor eerste voorkeuren - instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten - `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen @@ -49,6 +51,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - `npm run build` - `npm run start` - `npm run lint` +- `npm run test` ## Supabase Auth configuratie @@ -68,12 +71,15 @@ Gebruik alleen `.env.example` als template. Lokale bestanden zoals `.env` en ## 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_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` 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. ## UI foundation @@ -103,7 +109,7 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## Eerstvolgende bouwstappen -1. `ST-203` Budgetlogica implementeren -2. `ST-301` Activiteitenmodel en planning opzetten +1. `ST-301` Activiteitenmodel en planning opzetten +2. `ST-304` EnergyMeter en lopend totaal implementeren 3. `ST-401` Evaluatie- en dagoverzichtslus bouwen 4. `ST-105` RLS-policy tests en hardening afronden diff --git a/docs/README.md b/docs/README.md index 4121f1a..89bc97d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,14 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem - Authenticatie: `Supabase Auth` - UI foundation in de app: `Tailwind CSS + shadcn/ui` +## 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 +- Eerste unit tests voor budgetmapping draaien via `Vitest` + ## Generator - [generate_inspannings_monitor_docs.py](./generate_inspannings_monitor_docs.py) diff --git a/docs/backlog/inspannings-monitor-backlog.md b/docs/backlog/inspannings-monitor-backlog.md index 3099e37..d7488fc 100644 --- a/docs/backlog/inspannings-monitor-backlog.md +++ b/docs/backlog/inspannings-monitor-backlog.md @@ -64,13 +64,15 @@ 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. +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 | | --- | --- | --- | --- | -| ST-201 | EnergySlider en SleepQualityInput bouwen | UI | Check-in kan mobiel comfortabel worden ingevuld | -| ST-202 | Server action voor createMorningCheckIn | Build | Check-in wordt opgeslagen met juiste validatie | -| ST-203 | Budgetlogica implementeren | Logic | Score mapping en budgetberekening zijn consistent en testbaar | -| ST-204 | Check-instatus op dashboard tonen | UI | Gebruiker ziet direct score, niveau en budget | -| ST-205 | Unit tests voor score- en budgetmapping | QA | Belangrijkste grenswaarden zijn afgedekt | +| ST-201 | EnergySlider en SleepQualityInput bouwen | UI | Afgerond: check-in kan mobiel comfortabel worden ingevuld | +| ST-202 | Server action voor createMorningCheckIn | Build | Afgerond: check-in wordt opgeslagen met juiste validatie | +| ST-203 | Budgetlogica implementeren | Logic | Afgerond: score mapping en budgetberekening zijn consistent en testbaar | +| 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 | Afgerond: belangrijkste grenswaarden zijn afgedekt | ## EPIC-04 Dagplanning From 44bd946290330d9537f36bb689e51d3bbfc74c37 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 02:07:11 +0200 Subject: [PATCH 07/31] Implement ST-301 planning data model --- README.md | 4 +- docs/README.md | 1 + docs/backlog/inspannings-monitor-backlog.md | 2 +- lib/planning/options.ts | 29 +++ lib/planning/service.ts | 211 ++++++++++++++++++ lib/planning/types.ts | 52 +++++ ...9_create_activities_and_reference_data.sql | 142 ++++++++++++ 7 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 lib/planning/options.ts create mode 100644 lib/planning/service.ts create mode 100644 lib/planning/types.ts create mode 100644 supabase/migrations/20260419_create_activities_and_reference_data.sql diff --git a/README.md b/README.md index f67d0c2..09a7504 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - 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 - eerste unit tests voor budgetmapping via `Vitest` - korte onboardingflow voor eerste voorkeuren - instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten @@ -77,6 +78,7 @@ De huidige app gebruikt onder meer deze migraties: - `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` Voer deze SQL uit in de Supabase SQL Editor of via de Supabase CLI voordat je de profile-, check-in- en budgetlagen lokaal test. @@ -109,7 +111,7 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## Eerstvolgende bouwstappen -1. `ST-301` Activiteitenmodel en planning opzetten +1. `ST-302` Planningformulier bouwen 2. `ST-304` EnergyMeter en lopend totaal implementeren 3. `ST-401` Evaluatie- en dagoverzichtslus bouwen 4. `ST-105` RLS-policy tests en hardening afronden diff --git a/docs/README.md b/docs/README.md index 89bc97d..3909ce2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,7 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem - 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` legt nu ook het activiteitenmodel, categorieën en skip-redenen vast - Eerste unit tests voor budgetmapping draaien via `Vitest` ## Generator diff --git a/docs/backlog/inspannings-monitor-backlog.md b/docs/backlog/inspannings-monitor-backlog.md index d7488fc..57e63c0 100644 --- a/docs/backlog/inspannings-monitor-backlog.md +++ b/docs/backlog/inspannings-monitor-backlog.md @@ -80,7 +80,7 @@ Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig ene | 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-303 | Autocomplete op eerdere activiteiten toevoegen | UX | Veelgebruikte activiteiten zijn snel opnieuw te kiezen | | ST-304 | EnergyMeter en lopend totaal implementeren | Logic/UI | Totaal update direct na elke wijziging | diff --git a/lib/planning/options.ts b/lib/planning/options.ts new file mode 100644 index 0000000..20ddb2a --- /dev/null +++ b/lib/planning/options.ts @@ -0,0 +1,29 @@ +export const ACTIVITY_SOURCE_VALUES = ["planned", "ad_hoc"] as const; +export const ACTIVITY_STATUS_VALUES = [ + "planned", + "completed", + "skipped", + "adjusted", +] as const; +export const ACTIVITY_IMPACT_LEVEL_VALUES = ["laag", "midden", "hoog"] as const; +export const ACTIVITY_PRIORITY_LEVEL_VALUES = ["laag", "normaal", "hoog"] as const; + +export const SEEDED_ACTIVITY_CATEGORY_KEYS = [ + "huishouden", + "werk_studie", + "administratie", + "sociaal", + "beweging", + "rust_herstel", + "reizen", + "vrije_tijd", +] as const; + +export const SEEDED_SKIP_REASON_KEYS = [ + "energie_te_laag", + "prioriteit_veranderd", + "praktische_belemmering", + "duurde_langer_dan_verwacht", + "te_belastend", + "vergeten", +] as const; diff --git a/lib/planning/service.ts b/lib/planning/service.ts new file mode 100644 index 0000000..5825c59 --- /dev/null +++ b/lib/planning/service.ts @@ -0,0 +1,211 @@ +import { getAuthenticatedUser } from "@/lib/auth/session"; +import type { + ActivityCategory, + ActivityImpactLevel, + ActivityPriorityLevel, + ActivityRecord, + ActivitySource, + ActivitiesForDateStatus, + ActivityStatus, + SkipReason, +} from "@/lib/planning/types"; +import { ensureProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { createClient } from "@/lib/supabase/server"; + +type SupabaseServerClient = Awaited>; + +type ActivityCategoryRow = { + id: string; + key: string; + label_nl: string; + sort_order: number; + is_active: boolean; + created_at: string; +}; + +type SkipReasonRow = { + id: string; + key: string; + label_nl: string; + sort_order: number; + is_active: boolean; + created_at: string; +}; + +type ActivityRow = { + id: string; + user_id: string; + activity_date: string; + source: ActivitySource; + status: ActivityStatus; + name: string; + category_id: string; + duration_minutes: number; + impact_level: ActivityImpactLevel; + priority_level: ActivityPriorityLevel; + skip_reason_id: string | null; + notes: string | null; + created_at: string; + updated_at: string; +}; + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const ACTIVITY_CATEGORY_COLUMNS = + "id, key, label_nl, sort_order, is_active, created_at"; +const SKIP_REASON_COLUMNS = + "id, key, label_nl, sort_order, is_active, created_at"; +const ACTIVITY_COLUMNS = + "id, user_id, activity_date, source, status, name, category_id, duration_minutes, impact_level, priority_level, skip_reason_id, notes, created_at, updated_at"; + +function mapActivityCategoryRow(row: ActivityCategoryRow): ActivityCategory { + return { + id: row.id, + key: row.key, + labelNl: row.label_nl, + sortOrder: row.sort_order, + isActive: row.is_active, + createdAt: row.created_at, + }; +} + +function mapSkipReasonRow(row: SkipReasonRow): SkipReason { + return { + id: row.id, + key: row.key, + labelNl: row.label_nl, + sortOrder: row.sort_order, + isActive: row.is_active, + createdAt: row.created_at, + }; +} + +function mapActivityRow(row: ActivityRow): ActivityRecord { + return { + id: row.id, + userId: row.user_id, + activityDate: row.activity_date, + source: row.source, + status: row.status, + name: row.name, + categoryId: row.category_id, + durationMinutes: row.duration_minutes, + impactLevel: row.impact_level, + priorityLevel: row.priority_level, + skipReasonId: row.skip_reason_id, + notes: row.notes, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function getLocalDateForTimezone(timezone: string, date = new Date()) { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + + const parts = formatter.formatToParts(date); + const year = parts.find((part) => part.type === "year")?.value; + const month = parts.find((part) => part.type === "month")?.value; + const day = parts.find((part) => part.type === "day")?.value; + + if (!year || !month || !day) { + throw new Error("Lokale plandatum voor timezone kon niet worden bepaald."); + } + + return `${year}-${month}-${day}`; +} + +function assertIsoDate(value: string) { + if (!ISO_DATE_PATTERN.test(value)) { + throw new Error("Ongeldige plandatum. Gebruik het formaat YYYY-MM-DD."); + } +} + +async function readActivitiesByDate( + supabase: SupabaseServerClient, + userId: string, + activityDate: string, +): Promise { + const { data, error } = await supabase + .from("activities") + .select(ACTIVITY_COLUMNS) + .eq("user_id", userId) + .eq("activity_date", activityDate) + .order("created_at", { ascending: true }); + + if (error) { + throw new Error(`Activiteiten konden niet worden geladen: ${error.message}`); + } + + return (data ?? []).map(mapActivityRow); +} + +export async function listActivityCategories(): Promise { + const supabase = await createClient(); + const { data, error } = await supabase + .from("activity_categories") + .select(ACTIVITY_CATEGORY_COLUMNS) + .order("sort_order", { ascending: true }); + + if (error) { + throw new Error(`Activiteitcategorieën konden niet worden geladen: ${error.message}`); + } + + return (data ?? []).map(mapActivityCategoryRow); +} + +export async function listSkipReasons(): Promise { + const supabase = await createClient(); + const { data, error } = await supabase + .from("skip_reasons") + .select(SKIP_REASON_COLUMNS) + .order("sort_order", { ascending: true }); + + if (error) { + throw new Error(`Skip-redenen konden niet worden geladen: ${error.message}`); + } + + return (data ?? []).map(mapSkipReasonRow); +} + +export async function getActivitiesForDateForCurrentUser( + activityDate: string, +): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + return null; + } + + assertIsoDate(activityDate); + + const supabase = await createClient(); + return readActivitiesByDate(supabase, user.id, activityDate); +} + +export async function getTodayActivitiesForCurrentUser(): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + return null; + } + + const profileBundle = await ensureProfileBundleForCurrentUser(); + + if (!profileBundle) { + return null; + } + + const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone); + const supabase = await createClient(); + const activities = await readActivitiesByDate(supabase, user.id, activityDate); + + return { + timezone: profileBundle.profile.timezone, + activityDate, + activities, + }; +} diff --git a/lib/planning/types.ts b/lib/planning/types.ts new file mode 100644 index 0000000..52bc4fc --- /dev/null +++ b/lib/planning/types.ts @@ -0,0 +1,52 @@ +import { + ACTIVITY_IMPACT_LEVEL_VALUES, + ACTIVITY_PRIORITY_LEVEL_VALUES, + ACTIVITY_SOURCE_VALUES, + ACTIVITY_STATUS_VALUES, +} from "@/lib/planning/options"; + +export type ActivitySource = (typeof ACTIVITY_SOURCE_VALUES)[number]; +export type ActivityStatus = (typeof ACTIVITY_STATUS_VALUES)[number]; +export type ActivityImpactLevel = (typeof ACTIVITY_IMPACT_LEVEL_VALUES)[number]; +export type ActivityPriorityLevel = (typeof ACTIVITY_PRIORITY_LEVEL_VALUES)[number]; + +export type ActivityCategory = { + id: string; + key: string; + labelNl: string; + sortOrder: number; + isActive: boolean; + createdAt: string; +}; + +export type SkipReason = { + id: string; + key: string; + labelNl: string; + sortOrder: number; + isActive: boolean; + createdAt: string; +}; + +export type ActivityRecord = { + id: string; + userId: string; + activityDate: string; + source: ActivitySource; + status: ActivityStatus; + name: string; + categoryId: string; + durationMinutes: number; + impactLevel: ActivityImpactLevel; + priorityLevel: ActivityPriorityLevel; + skipReasonId: string | null; + notes: string | null; + createdAt: string; + updatedAt: string; +}; + +export type ActivitiesForDateStatus = { + timezone: string; + activityDate: string; + activities: ActivityRecord[]; +}; diff --git a/supabase/migrations/20260419_create_activities_and_reference_data.sql b/supabase/migrations/20260419_create_activities_and_reference_data.sql new file mode 100644 index 0000000..6687f05 --- /dev/null +++ b/supabase/migrations/20260419_create_activities_and_reference_data.sql @@ -0,0 +1,142 @@ +create table if not exists public.activity_categories ( + id uuid primary key, + key text not null unique, + label_nl text not null, + sort_order integer not null check (sort_order >= 1), + is_active boolean not null default true, + created_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.skip_reasons ( + id uuid primary key, + key text not null unique, + label_nl text not null, + sort_order integer not null check (sort_order >= 1), + is_active boolean not null default true, + created_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.activities ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + activity_date date not null, + source text not null default 'planned', + status text not null default 'planned', + name text not null, + category_id uuid not null references public.activity_categories (id), + duration_minutes integer not null, + impact_level text not null, + priority_level text not null, + skip_reason_id uuid references public.skip_reasons (id), + notes text, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + constraint activities_source_check + check (source in ('planned', 'ad_hoc')), + constraint activities_status_check + check (status in ('planned', 'completed', 'skipped', 'adjusted')), + constraint activities_name_check + check (char_length(trim(name)) between 1 and 120), + constraint activities_duration_minutes_check + check (duration_minutes > 0 and duration_minutes <= 720), + constraint activities_impact_level_check + check (impact_level in ('laag', 'midden', 'hoog')), + constraint activities_priority_level_check + check (priority_level in ('laag', 'normaal', 'hoog')) +); + +create index if not exists activities_user_date_idx + on public.activities (user_id, activity_date); + +create index if not exists activity_categories_sort_order_idx + on public.activity_categories (sort_order); + +create index if not exists skip_reasons_sort_order_idx + on public.skip_reasons (sort_order); + +grant select on table public.activity_categories to authenticated; +grant select on table public.skip_reasons to authenticated; +grant select, insert, update, delete on table public.activities to authenticated; + +alter table public.activity_categories enable row level security; +alter table public.skip_reasons enable row level security; +alter table public.activities enable row level security; + +drop trigger if exists set_activities_updated_at on public.activities; +create trigger set_activities_updated_at +before update on public.activities +for each row +execute function public.set_updated_at(); + +drop policy if exists "activity_categories_select_active" on public.activity_categories; +create policy "activity_categories_select_active" +on public.activity_categories +for select +to authenticated +using (is_active = true); + +drop policy if exists "skip_reasons_select_active" on public.skip_reasons; +create policy "skip_reasons_select_active" +on public.skip_reasons +for select +to authenticated +using (is_active = true); + +drop policy if exists "activities_select_own" on public.activities; +create policy "activities_select_own" +on public.activities +for select +to authenticated +using ((select auth.uid()) = user_id); + +drop policy if exists "activities_insert_own" on public.activities; +create policy "activities_insert_own" +on public.activities +for insert +to authenticated +with check ((select auth.uid()) = user_id); + +drop policy if exists "activities_update_own" on public.activities; +create policy "activities_update_own" +on public.activities +for update +to authenticated +using ((select auth.uid()) = user_id) +with check ((select auth.uid()) = user_id); + +drop policy if exists "activities_delete_own" on public.activities; +create policy "activities_delete_own" +on public.activities +for delete +to authenticated +using ((select auth.uid()) = user_id); + +insert into public.activity_categories (id, key, label_nl, sort_order, is_active) +values + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1001', 'huishouden', 'Huishouden', 1, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1002', 'werk_studie', 'Werk of studie', 2, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1003', 'administratie', 'Administratie', 3, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1004', 'sociaal', 'Sociaal', 4, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1005', 'beweging', 'Beweging', 5, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1006', 'rust_herstel', 'Rust en herstel', 6, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1007', 'reizen', 'Reizen', 7, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1008', 'vrije_tijd', 'Vrije tijd', 8, true) +on conflict (key) do update +set + label_nl = excluded.label_nl, + sort_order = excluded.sort_order, + is_active = excluded.is_active; + +insert into public.skip_reasons (id, key, label_nl, sort_order, is_active) +values + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142001', 'energie_te_laag', 'Energie te laag', 1, true), + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142002', 'prioriteit_veranderd', 'Prioriteit veranderde', 2, true), + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142003', 'praktische_belemmering', 'Praktische belemmering', 3, true), + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142004', 'duurde_langer_dan_verwacht', 'Vorige activiteit duurde langer', 4, true), + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142005', 'te_belastend', 'Te belastend', 5, true), + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142006', 'vergeten', 'Vergeten', 6, true) +on conflict (key) do update +set + label_nl = excluded.label_nl, + sort_order = excluded.sort_order, + is_active = excluded.is_active; From 5c15620993863c5d9d5301fb9225eb0689ba0cff Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 02:19:34 +0200 Subject: [PATCH 08/31] Implement ST-302 planning form flow --- README.md | 3 +- app/dashboard/page.tsx | 44 ++- app/planning/actions.ts | 71 +++++ app/planning/page.tsx | 162 +++++++++++ components/planning/activity-form.tsx | 266 ++++++++++++++++++ components/planning/today-activities-list.tsx | 99 +++++++ docs/README.md | 2 +- docs/backlog/inspannings-monitor-backlog.md | 4 +- lib/feedback/status-messages.ts | 32 +++ lib/forms/parse.ts | 28 ++ lib/planning/form-options.ts | 50 ++++ lib/planning/service.ts | 94 +++++++ lib/planning/types.ts | 15 + 13 files changed, 866 insertions(+), 4 deletions(-) create mode 100644 app/planning/actions.ts create mode 100644 app/planning/page.tsx create mode 100644 components/planning/activity-form.tsx create mode 100644 components/planning/today-activities-list.tsx create mode 100644 lib/planning/form-options.ts diff --git a/README.md b/README.md index 09a7504..2cf6a7e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - 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 - eerste unit tests voor budgetmapping via `Vitest` - korte onboardingflow voor eerste voorkeuren - instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten @@ -111,7 +112,7 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## Eerstvolgende bouwstappen -1. `ST-302` Planningformulier bouwen +1. `ST-303` Autocomplete op eerdere activiteiten toevoegen 2. `ST-304` EnergyMeter en lopend totaal implementeren 3. `ST-401` Evaluatie- en dagoverzichtslus bouwen 4. `ST-105` RLS-policy tests en hardening afronden diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b1071c5..5a00205 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -16,6 +16,7 @@ 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 { getProfileBundleForCurrentUser } from "@/lib/profile/service"; import { getParamValue, type PageSearchParams } from "@/lib/search-params"; import { cn } from "@/lib/utils"; @@ -53,7 +54,10 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps } const { profile, settings } = profileBundle; - const checkInStatus = await getTodayCheckInForCurrentUser(); + const [checkInStatus, planningStatus] = await Promise.all([ + getTodayCheckInForCurrentUser(), + getTodayActivitiesForCurrentUser(), + ]); const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status")); if (!profile.onboardingSeen) { @@ -97,6 +101,15 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps > Instellingen + + Dagplanning + {isTestWizardEnabled() ? ( + + +

+ Dagplanning +

+ + {planningStatus?.activities.length + ? `${planningStatus.activities.length} activiteiten voor vandaag` + : "Nog niets gepland voor vandaag"} + +
+ + + Plan kleine, concrete activiteiten voor vandaag en bouw daarna verder op budgetfeedback en evaluatie. + +
+ + Open dagplanning + +
+
+
+ {isTestWizardEnabled() ? ( diff --git a/app/planning/actions.ts b/app/planning/actions.ts new file mode 100644 index 0000000..b2e8bab --- /dev/null +++ b/app/planning/actions.ts @@ -0,0 +1,71 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { buildPathWithQuery } from "@/lib/auth/navigation"; +import { + ACTIVITY_IMPACT_LEVEL_VALUES, + ACTIVITY_PRIORITY_LEVEL_VALUES, +} from "@/lib/planning/options"; +import { createActivityForTodayForCurrentUser } from "@/lib/planning/service"; +import type { CreateActivitySubmission } from "@/lib/planning/types"; +import { + assertMaxLength, + FormDataValidationError, + getEnumValue, + getIntegerValue, + 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", + ), + }; +} + +export async function createActivityAction( + _previousState: null, + formData: FormData, +): Promise { + 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; +} diff --git a/app/planning/page.tsx b/app/planning/page.tsx new file mode 100644 index 0000000..b502ce3 --- /dev/null +++ b/app/planning/page.tsx @@ -0,0 +1,162 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { signOutAction } from "@/app/auth-actions"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; +import { ActivityForm } from "@/components/planning/activity-form"; +import { TodayActivitiesList } from "@/components/planning/today-activities-list"; +import { Button, buttonVariants } from "@/components/ui/button"; +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 { getProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; +import { cn } from "@/lib/utils"; + +export const dynamic = "force-dynamic"; + +type PlanningPageProps = { + searchParams: Promise; +}; + +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"), + ); + + return ( +
+
+ + +
+
+
+ + Dashboard + + / + Dagplanning +
+

+ Plan vandaag bewust klein +

+

+ Voeg alleen activiteiten toe die vandaag echt relevant zijn. Houd de lijst licht, + zodat je later goed kunt bijsturen zonder druk op te bouwen. +

+
+ +
+ + Terug naar dashboard + + + + +
+
+ +
+ + + +
+ + +
+
+ ); +} diff --git a/components/planning/activity-form.tsx b/components/planning/activity-form.tsx new file mode 100644 index 0000000..d774c9b --- /dev/null +++ b/components/planning/activity-form.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { useActionState, useMemo, useState } from "react"; +import { createActivityAction } from "@/app/planning/actions"; +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 type { ActivityCategory } from "@/lib/planning/types"; +import { cn } from "@/lib/utils"; + +type ActivityFormProps = { + categories: ActivityCategory[]; +}; + +export function ActivityForm({ categories }: ActivityFormProps) { + const [, formAction, isPending] = useActionState(createActivityAction, null); + const [name, setName] = useState(""); + const [categoryId, setCategoryId] = useState(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], + ); + + return ( +
+ + + + + + +

+ Dagplanning +

+ + Plan een activiteit voor vandaag + + + 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. + +
+ +
+ + setName(event.target.value)} + /> +
+ +
+
+ + + {selectedCategory ? ( +

+ Gekozen categorie: {selectedCategory.labelNl}. +

+ ) : null} +
+ +
+ + setDurationMinutes(event.target.value)} + /> +
+ {ACTIVITY_DURATION_SUGGESTIONS.map((value) => ( + + ))} +
+
+
+ + + +
+
+
+ +

+ Kies hoe belastend deze activiteit voor jou aanvoelt. +

+
+
+ {ACTIVITY_IMPACT_OPTIONS.map((option) => { + const isSelected = impactLevel === option.value; + + return ( + + ); + })} +
+
+ +
+
+ +

+ Dit helpt straks om bewust te herschikken zonder alles te verliezen. +

+
+
+ {ACTIVITY_PRIORITY_OPTIONS.map((option) => { + const isSelected = priorityLevel === option.value; + + return ( + + ); + })} +
+
+
+
+
+ +
+

+ {isPending + ? "Je activiteit wordt opgeslagen..." + : "Je activiteit wordt vandaag toegevoegd met status `gepland`."} +

+ + +
+
+ ); +} diff --git a/components/planning/today-activities-list.tsx b/components/planning/today-activities-list.tsx new file mode 100644 index 0000000..09ca8bb --- /dev/null +++ b/components/planning/today-activities-list.tsx @@ -0,0 +1,99 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { ActivityCategory, ActivityRecord } from "@/lib/planning/types"; + +type TodayActivitiesListProps = { + activities: ActivityRecord[]; + categories: ActivityCategory[]; +}; + +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"; +} + +export function TodayActivitiesList({ + activities, + categories, +}: TodayActivitiesListProps) { + return ( + + +

+ Vandaag gepland +

+ + {activities.length === 0 + ? "Nog geen activiteiten gepland" + : `${activities.length} ${activities.length === 1 ? "activiteit" : "activiteiten"}`} + +
+ + {activities.length === 0 ? ( + + Je dag is nog leeg. Plan eerst een kleine concrete activiteit om de flow op gang te brengen. + + ) : ( + activities.map((activity) => ( +
+
+
+

{activity.name}

+

+ {getCategoryLabel(categories, activity.categoryId)} +

+
+ + Gepland + +
+ +
+

+ Duur: {activity.durationMinutes} min +

+

+ Impact: {formatImpactLabel(activity.impactLevel)} +

+

+ Prioriteit: {formatPriorityLabel(activity.priorityLevel)} +

+
+
+ )) + )} +
+
+ ); +} diff --git a/docs/README.md b/docs/README.md index 3909ce2..acc522e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,7 +40,7 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem - 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` legt nu ook het activiteitenmodel, categorieën en skip-redenen vast +- `ST-301` en `ST-302` leggen nu ook het activiteitenmodel en de eerste dagplanningflow vast - Eerste unit tests voor budgetmapping draaien via `Vitest` ## Generator diff --git a/docs/backlog/inspannings-monitor-backlog.md b/docs/backlog/inspannings-monitor-backlog.md index 57e63c0..50b54ca 100644 --- a/docs/backlog/inspannings-monitor-backlog.md +++ b/docs/backlog/inspannings-monitor-backlog.md @@ -78,10 +78,12 @@ Status: `ST-201`, `ST-202`, `ST-203`, `ST-204` en `ST-205` zijn inmiddels gereal Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig energiemodel. +Status: `ST-301` en `ST-302` zijn inmiddels gerealiseerd in de app. De volgende logische stap ligt nu in `ST-303` en `ST-304`. + | Story ID | Titel | Type | Definition of done | | --- | --- | --- | --- | | 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-304 | EnergyMeter en lopend totaal implementeren | Logic/UI | Totaal update direct na elke wijziging | | ST-305 | Overschrijdingswaarschuwing toevoegen | UX | Gebruiker krijgt feedback maar behoudt regie | diff --git a/lib/feedback/status-messages.ts b/lib/feedback/status-messages.ts index 5bcd8ab..4afedf3 100644 --- a/lib/feedback/status-messages.ts +++ b/lib/feedback/status-messages.ts @@ -63,6 +63,23 @@ const checkInErrorToasts: Record = { }, }; +const planningStatusToasts: Record = { + "activity-saved": { + variant: "success", + title: "Activiteit gepland", + message: "Je activiteit staat nu in je dagplanning van vandaag.", + }, +}; + +const planningErrorToasts: Record = { + "invalid-activity-input": { + variant: "error", + title: "Activiteit niet opgeslagen", + message: + "Controleer naam, categorie, duur, impact en prioriteit en probeer het opnieuw.", + }, +}; + export function getDashboardStatusToast(status: string | null): StatusToast | null { if (!status) { return null; @@ -116,6 +133,21 @@ export function getCheckInStatusToast( return null; } +export function getPlanningStatusToast( + error: string | null, + status: string | null, +): StatusToast | null { + if (error && planningErrorToasts[error]) { + return planningErrorToasts[error]; + } + + if (!status) { + return null; + } + + return planningStatusToasts[status] ?? null; +} + export function getAuthStatusToast( error: string | null, status: string | null, diff --git a/lib/forms/parse.ts b/lib/forms/parse.ts index 81c40d1..de83e29 100644 --- a/lib/forms/parse.ts +++ b/lib/forms/parse.ts @@ -1,6 +1,8 @@ const TIME_VALUE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; const EMAIL_VALUE_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const INTEGER_VALUE_PATTERN = /^-?\d+$/; +const UUID_VALUE_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export class FormDataValidationError extends Error { code: string; @@ -117,6 +119,18 @@ export function assertMinLength( return value; } +export function assertMaxLength( + value: string, + maximumLength: number, + errorCode: string, +): string { + if (value.length > maximumLength) { + fail(errorCode); + } + + return value; +} + export function getIntegerValue( formData: FormData, key: string, @@ -141,3 +155,17 @@ export function getIntegerValue( return parsedValue; } + +export function getUuidValue( + formData: FormData, + key: string, + errorCode: string, +): string { + const value = getRequiredString(formData, key, errorCode); + + if (!UUID_VALUE_PATTERN.test(value)) { + fail(errorCode); + } + + return value; +} diff --git a/lib/planning/form-options.ts b/lib/planning/form-options.ts new file mode 100644 index 0000000..79c27e4 --- /dev/null +++ b/lib/planning/form-options.ts @@ -0,0 +1,50 @@ +import type { + ActivityImpactLevel, + ActivityPriorityLevel, +} from "@/lib/planning/types"; + +export const ACTIVITY_DURATION_SUGGESTIONS = [15, 30, 45, 60, 90, 120] as const; + +export const ACTIVITY_IMPACT_OPTIONS: ReadonlyArray<{ + value: ActivityImpactLevel; + label: string; + description: string; +}> = [ + { + value: "laag", + label: "Laag", + description: "Kleine activiteit met beperkte verwachte belasting.", + }, + { + value: "midden", + label: "Midden", + description: "Merkbare activiteit waarvoor je bewust ruimte wilt houden.", + }, + { + value: "hoog", + label: "Hoog", + description: "Zwaardere activiteit die waarschijnlijk meer herstel vraagt.", + }, +] as const; + +export const ACTIVITY_PRIORITY_OPTIONS: ReadonlyArray<{ + value: ActivityPriorityLevel; + label: string; + description: string; +}> = [ + { + value: "laag", + label: "Laag", + description: "Kan makkelijk verschuiven als je dag anders loopt.", + }, + { + value: "normaal", + label: "Normaal", + description: "Belangrijk genoeg om bewust in je dag mee te nemen.", + }, + { + value: "hoog", + label: "Hoog", + description: "Heeft vandaag duidelijk prioriteit, maar blijft wel een keuze.", + }, +] as const; diff --git a/lib/planning/service.ts b/lib/planning/service.ts index 5825c59..e5d0914 100644 --- a/lib/planning/service.ts +++ b/lib/planning/service.ts @@ -1,8 +1,10 @@ import { getAuthenticatedUser } from "@/lib/auth/session"; import type { ActivityCategory, + CreateActivitySubmission, ActivityImpactLevel, ActivityPriorityLevel, + PlanningPageData, ActivityRecord, ActivitySource, ActivitiesForDateStatus, @@ -143,6 +145,26 @@ async function readActivitiesByDate( return (data ?? []).map(mapActivityRow); } +async function assertCategoryExists( + supabase: SupabaseServerClient, + categoryId: string, +): Promise { + const { data, error } = await supabase + .from("activity_categories") + .select("id") + .eq("id", categoryId) + .eq("is_active", true) + .maybeSingle(); + + if (error) { + throw new Error(`Activiteitcategorie kon niet worden gevalideerd: ${error.message}`); + } + + if (!data) { + throw new Error("Ongeldige activiteitcategorie."); + } +} + export async function listActivityCategories(): Promise { const supabase = await createClient(); const { data, error } = await supabase @@ -209,3 +231,75 @@ export async function getTodayActivitiesForCurrentUser(): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + return null; + } + + const profileBundle = await ensureProfileBundleForCurrentUser(); + + if (!profileBundle) { + return null; + } + + const [categories, activitiesStatus] = await Promise.all([ + listActivityCategories(), + getTodayActivitiesForCurrentUser(), + ]); + + return { + timezone: profileBundle.profile.timezone, + activityDate: + activitiesStatus?.activityDate ?? getLocalDateForTimezone(profileBundle.profile.timezone), + categories, + activities: activitiesStatus?.activities ?? [], + }; +} + +export async function createActivityForTodayForCurrentUser( + submission: CreateActivitySubmission, +): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + throw new Error("Er is geen ingelogde gebruiker beschikbaar."); + } + + const profileBundle = await ensureProfileBundleForCurrentUser(); + + if (!profileBundle) { + throw new Error("Profielbundle ontbreekt voor de huidige gebruiker."); + } + + const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone); + const supabase = await createClient(); + + await assertCategoryExists(supabase, submission.categoryId); + + const { data, error } = await supabase + .from("activities") + .insert({ + user_id: user.id, + activity_date: activityDate, + source: "planned", + status: "planned", + name: submission.name, + category_id: submission.categoryId, + duration_minutes: submission.durationMinutes, + impact_level: submission.impactLevel, + priority_level: submission.priorityLevel, + skip_reason_id: null, + notes: null, + }) + .select(ACTIVITY_COLUMNS) + .single(); + + if (error) { + throw new Error(`Activiteit kon niet worden opgeslagen: ${error.message}`); + } + + return mapActivityRow(data); +} diff --git a/lib/planning/types.ts b/lib/planning/types.ts index 52bc4fc..1cf83a3 100644 --- a/lib/planning/types.ts +++ b/lib/planning/types.ts @@ -45,8 +45,23 @@ export type ActivityRecord = { updatedAt: string; }; +export type CreateActivitySubmission = { + name: string; + categoryId: string; + durationMinutes: number; + impactLevel: ActivityImpactLevel; + priorityLevel: ActivityPriorityLevel; +}; + export type ActivitiesForDateStatus = { timezone: string; activityDate: string; activities: ActivityRecord[]; }; + +export type PlanningPageData = { + timezone: string; + activityDate: string; + categories: ActivityCategory[]; + activities: ActivityRecord[]; +}; From c3e936b0dbf9c2d2761f7eca0dac9c614da1baef Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 02:25:52 +0200 Subject: [PATCH 09/31] Implement ST-304 energy meter --- README.md | 5 +- app/dashboard/page.tsx | 8 ++ app/planning/page.tsx | 18 +++- components/planning/activity-form.tsx | 60 ++++++++++- components/planning/energy-meter-card.tsx | 78 ++++++++++++++ components/planning/today-activities-list.tsx | 5 + docs/README.md | 4 +- docs/backlog/inspannings-monitor-backlog.md | 4 +- lib/planning/meter.test.ts | 89 +++++++++++++++ lib/planning/meter.ts | 101 ++++++++++++++++++ 10 files changed, 360 insertions(+), 12 deletions(-) create mode 100644 components/planning/energy-meter-card.tsx create mode 100644 lib/planning/meter.test.ts create mode 100644 lib/planning/meter.ts diff --git a/README.md b/README.md index 2cf6a7e..3e29c54 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - 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 -- eerste unit tests voor budgetmapping via `Vitest` +- energiemeter met lopend totaal ten opzichte van het dagbudget +- eerste unit tests voor budget- en meterlogica via `Vitest` - korte onboardingflow voor eerste voorkeuren - instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten - `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen @@ -113,6 +114,6 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## Eerstvolgende bouwstappen 1. `ST-303` Autocomplete op eerdere activiteiten toevoegen -2. `ST-304` EnergyMeter en lopend totaal implementeren +2. `ST-305` Overschrijdingswaarschuwing toevoegen 3. `ST-401` Evaluatie- en dagoverzichtslus bouwen 4. `ST-105` RLS-policy tests en hardening afronden diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 5a00205..87c8740 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { signOutAction } from "@/app/auth-actions"; import { CheckInCard } from "@/components/check-in/check-in-card"; import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; +import { EnergyMeterCard } from "@/components/planning/energy-meter-card"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -17,6 +18,7 @@ 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 { getParamValue, type PageSearchParams } from "@/lib/search-params"; import { cn } from "@/lib/utils"; @@ -69,6 +71,10 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps const morningReminderState = settings.morningReminderEnabled ? `Aan om ${formatReminderTime(settings.morningReminderTime)}` : "Uit"; + const planningMeter = calculatePlanningMeterSnapshot( + planningStatus?.activities ?? [], + checkInStatus?.todayCheckIn?.dailyBudget ?? null, + ); return (
@@ -221,6 +227,8 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps + + {isTestWizardEnabled() ? ( diff --git a/app/planning/page.tsx b/app/planning/page.tsx index b502ce3..a6793d8 100644 --- a/app/planning/page.tsx +++ b/app/planning/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { signOutAction } from "@/app/auth-actions"; import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { ActivityForm } from "@/components/planning/activity-form"; +import { EnergyMeterCard } from "@/components/planning/energy-meter-card"; import { TodayActivitiesList } from "@/components/planning/today-activities-list"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -17,6 +18,7 @@ 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"; import { cn } from "@/lib/utils"; @@ -62,6 +64,10 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps) getParamValue(resolvedSearchParams, "error"), getParamValue(resolvedSearchParams, "status"), ); + const planningMeter = calculatePlanningMeterSnapshot( + planningPageData.activities, + checkInStatus?.todayCheckIn?.dailyBudget ?? null, + ); return (
@@ -105,7 +111,11 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
- + diff --git a/components/planning/activity-form.tsx b/components/planning/activity-form.tsx index d774c9b..8faac94 100644 --- a/components/planning/activity-form.tsx +++ b/components/planning/activity-form.tsx @@ -25,14 +25,17 @@ import { ACTIVITY_IMPACT_OPTIONS, ACTIVITY_PRIORITY_OPTIONS, } from "@/lib/planning/form-options"; -import type { ActivityCategory } from "@/lib/planning/types"; +import { calculatePlanningMeterSnapshot, deriveActivityEnergyPoints } from "@/lib/planning/meter"; +import type { ActivityCategory, ActivityRecord } from "@/lib/planning/types"; import { cn } from "@/lib/utils"; type ActivityFormProps = { categories: ActivityCategory[]; + activities: ActivityRecord[]; + dailyBudget: number | null; }; -export function ActivityForm({ categories }: ActivityFormProps) { +export function ActivityForm({ categories, activities, dailyBudget }: ActivityFormProps) { const [, formAction, isPending] = useActionState(createActivityAction, null); const [name, setName] = useState(""); const [categoryId, setCategoryId] = useState(categories[0]?.id ?? ""); @@ -44,6 +47,40 @@ export function ActivityForm({ categories }: ActivityFormProps) { () => 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]); return (
@@ -148,6 +185,23 @@ export function ActivityForm({ categories }: ActivityFormProps) { + + +

Vooruitblik op de meter

+

+ {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?.plannedPoints ?? currentMeter.plannedPoints} geplande punten.`} +

+ {dailyBudget !== null && previewMeter ? ( +

+ Dat is {previewMeter.dailyBudget} punten budget, met daarna nog{" "} + {previewMeter.remainingBudget} punten ruimte. +

+ ) : null} +
+
+
@@ -244,7 +298,7 @@ export function ActivityForm({ categories }: ActivityFormProps) {

{isPending ? "Je activiteit wordt opgeslagen..." - : "Je activiteit wordt vandaag toegevoegd met status `gepland`."} + : "Je activiteit wordt vandaag toegevoegd met status `gepland`, waarna de meter direct opnieuw wordt berekend."}

)) diff --git a/docs/README.md b/docs/README.md index acc522e..5686559 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,8 +40,8 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem - 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` en `ST-302` leggen nu ook het activiteitenmodel en de eerste dagplanningflow vast -- Eerste unit tests voor budgetmapping draaien via `Vitest` +- `ST-301`, `ST-302` en `ST-304` leggen nu ook het activiteitenmodel en de eerste dagplanningflow vast +- Eerste unit tests voor budget- en meterlogica draaien via `Vitest` ## Generator diff --git a/docs/backlog/inspannings-monitor-backlog.md b/docs/backlog/inspannings-monitor-backlog.md index 50b54ca..fc3c40a 100644 --- a/docs/backlog/inspannings-monitor-backlog.md +++ b/docs/backlog/inspannings-monitor-backlog.md @@ -78,14 +78,14 @@ Status: `ST-201`, `ST-202`, `ST-203`, `ST-204` en `ST-205` zijn inmiddels gereal Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig energiemodel. -Status: `ST-301` en `ST-302` zijn inmiddels gerealiseerd in de app. De volgende logische stap ligt nu in `ST-303` en `ST-304`. +Status: `ST-301`, `ST-302` en `ST-304` zijn inmiddels gerealiseerd in de app. De volgende logische stap ligt nu in `ST-303` en `ST-305`. | Story ID | Titel | Type | Definition of done | | --- | --- | --- | --- | | 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 | 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-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 | ## EPIC-05 Evaluatie en dagoverzicht diff --git a/lib/planning/meter.test.ts b/lib/planning/meter.test.ts new file mode 100644 index 0000000..cc4af33 --- /dev/null +++ b/lib/planning/meter.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + calculatePlannedPointsTotal, + calculatePlanningMeterSnapshot, + deriveActivityEnergyPoints, +} from "./meter"; + +describe("deriveActivityEnergyPoints", () => { + it("geeft 1 punt voor een korte lage activiteit", () => { + expect( + deriveActivityEnergyPoints({ durationMinutes: 15, impactLevel: "laag", status: "planned" }), + ).toBe(1); + }); + + it("geeft 2 punten voor een middellange middenactiviteit", () => { + expect( + deriveActivityEnergyPoints({ + durationMinutes: 30, + impactLevel: "midden", + status: "planned", + }), + ).toBe(2); + }); + + it("geeft 4 punten voor een langere hoge activiteit", () => { + expect( + deriveActivityEnergyPoints({ durationMinutes: 60, impactLevel: "hoog", status: "planned" }), + ).toBe(4); + }); + + it("telt een geskipt item niet mee", () => { + expect( + deriveActivityEnergyPoints({ durationMinutes: 45, impactLevel: "hoog", status: "skipped" }), + ).toBe(0); + }); +}); + +describe("calculatePlanningMeterSnapshot", () => { + it("somt punten van activiteiten op", () => { + expect( + calculatePlannedPointsTotal([ + { durationMinutes: 30, impactLevel: "midden", status: "planned" }, + { durationMinutes: 90, impactLevel: "laag", status: "planned" }, + ]), + ).toBe(4); + }); + + it("berekent resterend budget en percentage", () => { + const snapshot = calculatePlanningMeterSnapshot( + [ + { durationMinutes: 30, impactLevel: "midden", status: "planned" }, + { durationMinutes: 60, impactLevel: "hoog", status: "planned" }, + ], + 8, + ); + + expect(snapshot.plannedPoints).toBe(6); + expect(snapshot.remainingBudget).toBe(2); + expect(snapshot.progressPercent).toBe(75); + expect(snapshot.isOverBudget).toBe(false); + }); + + it("werkt ook zonder dagbudget", () => { + const snapshot = calculatePlanningMeterSnapshot( + [{ durationMinutes: 45, impactLevel: "midden", status: "planned" }], + null, + ); + + expect(snapshot.plannedPoints).toBe(2); + expect(snapshot.dailyBudget).toBeNull(); + expect(snapshot.remainingBudget).toBeNull(); + expect(snapshot.progressPercent).toBeNull(); + }); + + it("markeert overschrijding zonder alarmerende blokkade", () => { + const snapshot = calculatePlanningMeterSnapshot( + [ + { durationMinutes: 60, impactLevel: "hoog", status: "planned" }, + { durationMinutes: 120, impactLevel: "hoog", status: "planned" }, + ], + 6, + ); + + expect(snapshot.plannedPoints).toBe(9); + expect(snapshot.remainingBudget).toBe(-3); + expect(snapshot.isOverBudget).toBe(true); + expect(snapshot.progressPercent).toBe(100); + }); +}); diff --git a/lib/planning/meter.ts b/lib/planning/meter.ts new file mode 100644 index 0000000..a88b882 --- /dev/null +++ b/lib/planning/meter.ts @@ -0,0 +1,101 @@ +import type { + ActivityImpactLevel, + ActivityRecord, + ActivityStatus, +} from "@/lib/planning/types"; + +export type PlanningMeterSnapshot = { + plannedPoints: number; + activityCount: number; + dailyBudget: number | null; + remainingBudget: number | null; + progressRatio: number | null; + progressPercent: number | null; + isOverBudget: boolean; +}; + +type ActivityMeterInput = { + durationMinutes: number; + impactLevel: ActivityImpactLevel; + status?: ActivityStatus; +}; + +function deriveDurationBandPoints(durationMinutes: number) { + if (durationMinutes <= 15) { + return 1; + } + + if (durationMinutes <= 45) { + return 2; + } + + if (durationMinutes <= 90) { + return 3; + } + + return 4; +} + +function deriveImpactAdjustment(impactLevel: ActivityImpactLevel) { + if (impactLevel === "laag") { + return -1; + } + + if (impactLevel === "hoog") { + return 1; + } + + return 0; +} + +export function deriveActivityEnergyPoints(input: ActivityMeterInput): number { + if (input.status === "skipped") { + return 0; + } + + return Math.max( + 1, + deriveDurationBandPoints(input.durationMinutes) + deriveImpactAdjustment(input.impactLevel), + ); +} + +export function calculatePlannedPointsTotal( + activities: Pick[], +): number { + return activities.reduce( + (total, activity) => total + deriveActivityEnergyPoints(activity), + 0, + ); +} + +export function calculatePlanningMeterSnapshot( + activities: Pick[], + dailyBudget: number | null, +): PlanningMeterSnapshot { + const plannedPoints = calculatePlannedPointsTotal(activities); + + if (dailyBudget === null) { + return { + plannedPoints, + activityCount: activities.length, + dailyBudget: null, + remainingBudget: null, + progressRatio: null, + progressPercent: null, + isOverBudget: false, + }; + } + + const remainingBudget = dailyBudget - plannedPoints; + const progressRatio = dailyBudget > 0 ? plannedPoints / dailyBudget : 0; + + return { + plannedPoints, + activityCount: activities.length, + dailyBudget, + remainingBudget, + progressRatio, + progressPercent: Math.min(100, Math.round(progressRatio * 100)), + isOverBudget: remainingBudget < 0, + }; +} From e269a155da031cb34423c747e99d892deeb0077a Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 02:36:11 +0200 Subject: [PATCH 10/31] Implement ST-305 budget warning feedback --- README.md | 5 +-- components/planning/activity-form.tsx | 11 +++++++ components/planning/energy-meter-card.tsx | 36 ++++++++++++++++++--- docs/README.md | 2 +- docs/backlog/inspannings-monitor-backlog.md | 4 +-- 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3e29c54..9d63efe 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - planningsfundering met activiteitenmodel, categorieën en skip-redenen in Supabase - planningpagina voor vandaag met activiteit toevoegen en directe lijstweergave - 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 - instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten @@ -114,6 +115,6 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## Eerstvolgende bouwstappen 1. `ST-303` Autocomplete op eerdere activiteiten toevoegen -2. `ST-305` Overschrijdingswaarschuwing toevoegen -3. `ST-401` Evaluatie- en dagoverzichtslus bouwen +2. `ST-401` Evaluatie- en dagoverzichtslus bouwen +3. `ST-404` Dagoverzicht bouwen 4. `ST-105` RLS-policy tests en hardening afronden diff --git a/components/planning/activity-form.tsx b/components/planning/activity-form.tsx index 8faac94..37224d1 100644 --- a/components/planning/activity-form.tsx +++ b/components/planning/activity-form.tsx @@ -2,6 +2,7 @@ import { useActionState, useMemo, useState } from "react"; import { createActivityAction } from "@/app/planning/actions"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -199,6 +200,16 @@ export function ActivityForm({ categories, activities, dailyBudget }: ActivityFo {previewMeter.remainingBudget} punten ruimte.

) : null} + {previewMeter?.isOverBudget ? ( + + Niet-blokkerende waarschuwing + + Met deze activiteit kom je ongeveer{" "} + {Math.abs(previewMeter.remainingBudget ?? 0)} punten boven je dagbudget uit. + Je kunt nog steeds opslaan, maar dit is een goed moment om bewust te heroverwegen of te versimpelen. + + + ) : null} diff --git a/components/planning/energy-meter-card.tsx b/components/planning/energy-meter-card.tsx index 0cc0edc..1f68626 100644 --- a/components/planning/energy-meter-card.tsx +++ b/components/planning/energy-meter-card.tsx @@ -1,3 +1,8 @@ +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert"; import { Card, CardContent, @@ -25,6 +30,18 @@ function formatRemainingLabel(remainingBudget: number) { 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 planning 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 je activiteiten."; +} + export function EnergyMeterCard({ meter, tone = "default", @@ -48,15 +65,16 @@ export function EnergyMeterCard({ - {meter.dailyBudget === null - ? "Er is nog geen dagbudget beschikbaar. De meter wordt actief zodra je ochtendcheck-in van vandaag er staat." - : "De meter blijft bewust eenvoudig: punten volgen uit duur en impact van je activiteiten."} + {getMeterDescription(meter)}
@@ -72,6 +90,16 @@ export function EnergyMeterCard({ ) : null}
+ + {meter.dailyBudget !== null && meter.isOverBudget ? ( + + Je zit boven je dagbudget + + Je planning komt nu {Math.abs(meter.remainingBudget ?? 0)} punten 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. + + + ) : null}
); diff --git a/docs/README.md b/docs/README.md index 5686559..816379e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,7 +40,7 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem - 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` en `ST-304` leggen nu ook het activiteitenmodel en de eerste dagplanningflow vast +- `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 diff --git a/docs/backlog/inspannings-monitor-backlog.md b/docs/backlog/inspannings-monitor-backlog.md index fc3c40a..34a704e 100644 --- a/docs/backlog/inspannings-monitor-backlog.md +++ b/docs/backlog/inspannings-monitor-backlog.md @@ -78,7 +78,7 @@ Status: `ST-201`, `ST-202`, `ST-203`, `ST-204` en `ST-205` zijn inmiddels gereal Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig energiemodel. -Status: `ST-301`, `ST-302` en `ST-304` zijn inmiddels gerealiseerd in de app. De volgende logische stap ligt nu in `ST-303` en `ST-305`. +Status: `ST-301`, `ST-302`, `ST-304` en `ST-305` zijn inmiddels gerealiseerd in de app. De volgende logische stap ligt nu in `ST-303` en `EPIC-05`. | Story ID | Titel | Type | Definition of done | | --- | --- | --- | --- | @@ -86,7 +86,7 @@ Status: `ST-301`, `ST-302` en `ST-304` zijn inmiddels gerealiseerd in de app. De | 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-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 From 928093975630d17110f4f9c3bdd427bbd23accb1 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 03:27:40 +0200 Subject: [PATCH 11/31] Implement Dusk theme system and polish --- README.md | 6 + app/check-in/page.tsx | 16 +- app/dashboard/page.tsx | 42 +- app/globals.css | 266 +++++--- app/layout.tsx | 34 +- app/login/page.tsx | 8 +- app/onboarding/page.tsx | 2 +- app/page.tsx | 29 +- app/planning/page.tsx | 16 +- app/settings/page.tsx | 18 +- app/sign-up/page.tsx | 8 +- app/wizard-test/page.tsx | 2 +- components/auth/auth-notice.tsx | 12 +- components/auth/auth-panel.tsx | 12 +- components/check-in/check-in-card.tsx | 4 +- components/check-in/check-in-form.tsx | 32 +- components/onboarding/onboarding-flow.tsx | 2 +- .../onboarding/onboarding-step-intro.tsx | 4 +- .../onboarding-step-preferences.tsx | 16 +- .../onboarding/onboarding-step-profile.tsx | 6 +- components/planning/activity-form.tsx | 57 +- components/planning/energy-meter-card.tsx | 37 +- components/planning/today-activities-list.tsx | 10 +- components/settings/settings-form.tsx | 34 +- components/theme-provider.tsx | 10 + components/ui/alert.tsx | 13 +- components/ui/button.tsx | 23 +- components/ui/card.tsx | 37 +- components/ui/checkbox.tsx | 2 +- components/ui/input.tsx | 2 +- components/ui/select.tsx | 4 +- components/ui/sonner.tsx | 11 +- components/ui/switch.tsx | 2 +- components/wizard/test-wizard-flow.tsx | 4 +- components/wizard/wizard-progress.tsx | 6 +- components/wizard/wizard-shell.tsx | 4 +- docs/README.md | 3 + ...-monitor-09-dusk-theme-specificatie-v01.md | 620 ++++++++++++++++++ 38 files changed, 1144 insertions(+), 270 deletions(-) create mode 100644 components/theme-provider.tsx create mode 100644 docs/inspannings-monitor-09-dusk-theme-specificatie-v01.md diff --git a/README.md b/README.md index 9d63efe..5b03e63 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - korte onboardingflow voor eerste voorkeuren - instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten - `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen +- `Dusk`-theme met dark-mode prioriteit, semantische oppervlakken en verbeterde focus-/toegankelijkheidsstijlen ## Stack @@ -94,6 +95,10 @@ knoppen, formulieren, kaarten en meldingen. De theme tokens staan centraal in 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. + ## Interne wizard-test Er is een interne testwizard beschikbaar op `/wizard-test` om een toekomstige @@ -109,6 +114,7 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## Documentatie - 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) - Implementatieplan en backlog: [inspannings-monitor-06-implementatieplan-en-backlog-v01.docx](/Users/janpetervisser/Development/third/docs/inspannings-monitor-06-implementatieplan-en-backlog-v01.docx) diff --git a/app/check-in/page.tsx b/app/check-in/page.tsx index ac869ce..ac15a41 100644 --- a/app/check-in/page.tsx +++ b/app/check-in/page.tsx @@ -55,14 +55,14 @@ export default async function CheckInPage({ searchParams }: CheckInPageProps) { ); return ( -
+
-
+
-
- +
+ Dashboard / @@ -71,7 +71,7 @@ export default async function CheckInPage({ searchParams }: CheckInPageProps) {

Ochtendcheck-in van vandaag

-

+

Houd je start rustig en klein. Je legt alleen een energiescore en een globale slaapindruk vast voor vandaag.

@@ -99,12 +99,12 @@ export default async function CheckInPage({ searchParams }: CheckInPageProps) {
-
+ ); } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index f6b2dff..4b39fd9 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,10 +1,10 @@ import Link from "next/link"; import { redirect } from "next/navigation"; -import { signOutAction } from "@/app/auth-actions"; import { CheckInCard } from "@/components/check-in/check-in-card"; import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; +import { AppShell } from "@/components/navigation/app-shell"; +import { PageIntro } from "@/components/navigation/page-intro"; import { EnergyMeterCard } from "@/components/planning/energy-meter-card"; -import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, @@ -21,7 +21,6 @@ import { getTodayActivitiesForCurrentUser } from "@/lib/planning/service"; import { calculatePlanningMeterSnapshot } from "@/lib/planning/meter"; import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; import { getParamValue, type PageSearchParams } from "@/lib/search-params"; -import { cn } from "@/lib/utils"; export const dynamic = "force-dynamic"; @@ -77,62 +76,25 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps ); return ( -
-
+ +
-
-
-

- Protected route -

-

- Dashboard placeholder voor release 1 -

-

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

-
- -
-
+ - Instellingen + Test wizard - - Dagplanning - - {isTestWizardEnabled() ? ( - - Test wizard - - ) : null} - -
-
-
+ ) : null + } + />
@@ -214,13 +176,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps Plan kleine, concrete activiteiten voor vandaag en bouw daarna verder op budgetfeedback en evaluatie.
- + Open dagplanning
@@ -256,15 +212,9 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps en eerste voorkeuren vast te leggen.

- - Rond onboarding af - + + Rond onboarding af + ) : ( @@ -277,19 +227,13 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps timezone en zichtbaarheid van punten later zelfstandig kunt aanpassen.

- - Open instellingen - - - - )} + + Open instellingen + + + + )} -
+ ); } diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index 558bba5..43b5189 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -1,5 +1,6 @@ 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 { sanitizeNextPath } from "@/lib/auth/navigation"; import { getAuthState } from "@/lib/auth/session"; @@ -41,11 +42,11 @@ export default async function OnboardingPage({ searchParams }: OnboardingPagePro ); return ( -
-
+ +
-
+ ); } diff --git a/app/page.tsx b/app/page.tsx index 1d9ed9d..637834a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; -import { signOutAction } from "@/app/auth-actions"; +import { AppShell } from "@/components/navigation/app-shell"; +import { PageIntro } from "@/components/navigation/page-intro"; import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; -import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, @@ -9,14 +9,12 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { getAuthState } from "@/lib/auth/session"; import { getAuthStatusToast } from "@/lib/feedback/status-messages"; import { getParamValue, type PageSearchParams } from "@/lib/search-params"; -import { cn } from "@/lib/utils"; export const dynamic = "force-dynamic"; -const loopSteps = [ +const productLoop = [ { title: "Check-in", copy: "Start de dag met een korte energiescore en slaapkwaliteit, zonder overbodige frictie.", @@ -31,8 +29,15 @@ 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", + "Volwassen doelgroep en Nederlands als voertaal", "Wellness/self-management positionering", "Geen sharing, AI of medische workflows in de MVP", "Vercel + Supabase als technische basis", @@ -43,7 +48,6 @@ type HomePageProps = { }; export default async function Home({ searchParams }: HomePageProps) { - const authState = await getAuthState(); const resolvedSearchParams = await searchParams; const statusToast = getAuthStatusToast( getParamValue(resolvedSearchParams, "error"), @@ -51,95 +55,38 @@ export default async function Home({ searchParams }: HomePageProps) { ); return ( -
-
-
-
-

- Inspannings Monitor -

-

- Rustige basis voor een wellness-first MVP -

-
-
- {authState.isConfigured ? ( - authState.isAuthenticated ? ( - <> - - Naar dashboard - -
- -
- - ) : ( - <> - - Inloggen - - - Account aanmaken - - - ) - ) : ( - - Supabase nog niet geconfigureerd - - )} -
-
- + +
-
- + + Bekijk planning + + } + /> + +
+ -

- De projectbasis staat nu, inclusief de eerste auth-laag via Supabase. - Release 1 blijft bewust smal: publieke landing, aparte login/signup - routes en een eerste protected dashboard als basis voor de volgende stories. +

+ Deze app wordt ontwikkeld door Jan Peter Visser als compacte dagtool + voor energieplanning en zelfregie. Het doel is niet om te diagnosticeren + of te behandelen, maar om een rustige plan-doe-evalueer-structuur te bieden + die licht genoeg blijft voor dagelijks gebruik.

-
- {loopSteps.map((step, index) => ( - - -

- Stap {index + 1} -

- - {step.title} - -
- - - {step.copy} - +
+ {makerNotes.map((note) => ( + + + {note} ))} @@ -147,14 +94,14 @@ export default async function Home({ searchParams }: HomePageProps) { - +

- Release 1 focus + Specificaties van de app

- {releaseFocus.map((item) => ( + {appSpecs.map((item) => ( {item}
))} - {authState.isConfigured ? ( -

- Auth is ingericht met e-mail, wachtwoord en verplichte e-mailverificatie. -

- ) : ( -

- Voeg `.env.local` toe om login, signup en protected routes lokaal te activeren. -

- )} +

+ De huidige codebasis bevat al auth, onboarding, ochtendcheck-in, + planning, energiemeter en Dusk-theming. +

- + + +

+ Dagflow +

+ De hoofdstructuur van release 1 +
+ + {productLoop.map((step, index) => ( + + +

+ Stap {index + 1} +

+ + {step.title} + +
+ + + {step.copy} + + +
+ ))} +
+
+ +

- Volgende story + Positionering

-

ST-201 Ochtendcheck-in

+

Wellness / self-management

@@ -191,19 +161,19 @@ export default async function Home({ searchParams }: HomePageProps) {

- Positionering + Auth en data

-

Wellness / self-management

+

Supabase Auth + PostgreSQL

- Status + Hosting

-

Auth, onboarding en settings actief

+

Next.js op Vercel

-
+ ); } diff --git a/app/planning/page.tsx b/app/planning/page.tsx index e508d71..ac02874 100644 --- a/app/planning/page.tsx +++ b/app/planning/page.tsx @@ -1,11 +1,11 @@ import Link from "next/link"; 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 { ActivityForm } from "@/components/planning/activity-form"; import { EnergyMeterCard } from "@/components/planning/energy-meter-card"; import { TodayActivitiesList } from "@/components/planning/today-activities-list"; -import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, @@ -21,7 +21,6 @@ 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"; -import { cn } from "@/lib/utils"; export const dynamic = "force-dynamic"; @@ -70,45 +69,23 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps) ); return ( -
-
+ +
-
-
-
- - Dashboard - - / - Dagplanning -
-

- Plan vandaag bewust klein -

-

- Voeg alleen activiteiten toe die vandaag echt relevant zijn. Houd de lijst licht, - zodat je later goed kunt bijsturen zonder druk op te bouwen. -

-
- -
+ Terug naar dashboard -
- -
-
-
+ } + />

Deze planning blokkeert je niet en geeft nog geen harde waarschuwingen.

Je meter gebruikt een eenvoudige, uitlegbare afleiding uit duur en impact.

-

Niet-blokkerende overschrijdingsfeedback volgt in `ST-305`.

+

Bij overschrijding krijg je nu een warme, niet-blokkerende waarschuwing in plaats van een harde blokkade.

@@ -169,6 +146,6 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps) categories={planningPageData.categories} />
-
+ ); } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 2149471..12f7dd9 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,9 +1,9 @@ import Link from "next/link"; 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 { SettingsForm } from "@/components/settings/settings-form"; -import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, @@ -16,7 +16,6 @@ import { getAuthState } from "@/lib/auth/session"; import { getSettingsStatusToast } from "@/lib/feedback/status-messages"; import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; import { getParamValue, type PageSearchParams } from "@/lib/search-params"; -import { cn } from "@/lib/utils"; export const dynamic = "force-dynamic"; @@ -57,45 +56,23 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps) "Ingelogde gebruiker"; return ( -
-
+ +
-
-
-
- - Dashboard - - / - Instellingen -
-

- Instellingen -

-

- Pas je basisvoorkeuren rustig aan. Alles blijft beperkt tot jouw eigen - account en de wellness-first scope van release 1. -

-
- -
+ Terug naar dashboard -
- -
-
-
+ } + />
@@ -134,6 +111,6 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
-
+ ); } diff --git a/app/wizard-test/page.tsx b/app/wizard-test/page.tsx index 2e0151b..d9c851f 100644 --- a/app/wizard-test/page.tsx +++ b/app/wizard-test/page.tsx @@ -1,4 +1,5 @@ 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"; @@ -22,10 +23,10 @@ export default async function WizardTestPage() { } return ( -
-
+ +
-
+ ); } diff --git a/components/navigation/account-menu.tsx b/components/navigation/account-menu.tsx new file mode 100644 index 0000000..5ed8694 --- /dev/null +++ b/components/navigation/account-menu.tsx @@ -0,0 +1,89 @@ +"use client"; + +import Link from "next/link"; +import { CircleUserRoundIcon, LayoutDashboardIcon, LogInIcon, LogOutIcon, 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 ( + + + + Account + + + {authState.isConfigured ? ( + authState.isAuthenticated ? ( + <> + + {authState.email ?? "Ingelogde gebruiker"} + + + } + > + + Dashboard + + } + > + + Check-in + + +
+ } + > + + Uitloggen + +
+ + ) : ( + <> + Niet ingelogd + + } + > + + Inloggen + + } + > + + Account aanmaken + + + ) + ) : ( + <> + Account + + + + Auth nog niet geconfigureerd + + + )} +
+
+ ); +} diff --git a/components/navigation/app-shell.tsx b/components/navigation/app-shell.tsx new file mode 100644 index 0000000..131d8d2 --- /dev/null +++ b/components/navigation/app-shell.tsx @@ -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 ( +
+
+ +
{children}
+
+
+ ); +} diff --git a/components/navigation/page-intro.tsx b/components/navigation/page-intro.tsx new file mode 100644 index 0000000..00f8e36 --- /dev/null +++ b/components/navigation/page-intro.tsx @@ -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 ( +
+
+

+ {eyebrow} +

+

+ {title} +

+

+ {description} +

+
+ {aside ?
{aside}
: null} +
+ ); +} diff --git a/components/navigation/theme-menu.tsx b/components/navigation/theme-menu.tsx new file mode 100644 index 0000000..0a2f4c6 --- /dev/null +++ b/components/navigation/theme-menu.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { MoonStarIcon, MonitorCogIcon, SunMediumIcon } from "lucide-react"; +import { useTheme } from "next-themes"; +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; + +function getThemeIcon(theme: string | undefined) { + if (theme === "light") { + return ; + } + + if (theme === "dark") { + return ; + } + + return ; +} + +export function ThemeMenu() { + const { theme, setTheme } = useTheme(); + + return ( + + + {getThemeIcon(theme)} + Theme + + + Weergave + + {themeOptions.map((option) => { + const OptionIcon = option.icon; + + return ( + + + {option.label} + + ); + })} + + + + ); +} diff --git a/components/navigation/top-nav.tsx b/components/navigation/top-nav.tsx new file mode 100644 index 0000000..0dd2c47 --- /dev/null +++ b/components/navigation/top-nav.tsx @@ -0,0 +1,90 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { ActivityIcon, InfoIcon, Settings2Icon } 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: "/planning", + label: "Planning", + icon: ActivityIcon, + }, + { + href: "/settings", + label: "Instellingen", + icon: Settings2Icon, + }, +] 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 ( +
+
+ + + Inspannings Monitor + + + Wellness-first dagflow + + + + + +
+ + +
+
+
+ ); +} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..c48ae2d --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -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) { + return ( + + {children} + + + ); +} + +type DropdownMenuContentProps = React.ComponentProps & + Pick< + React.ComponentProps, + "align" | "alignOffset" | "side" | "sideOffset" + >; + +function DropdownMenuContent({ + className, + children, + side = "bottom", + sideOffset = 10, + align = "end", + alignOffset = 0, + ...props +}: DropdownMenuContentProps) { + return ( + + + + {children} + + + + ); +} + +function DropdownMenuLabel({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DropdownMenuItem({ + className, + inset = false, + ...props +}: React.ComponentProps & { inset?: boolean }) { + return ( + + ); +} + +function DropdownMenuRadioGroup( + props: React.ComponentProps, +) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +}; From d0739736aaa9cc55e502a12e9a15af5dc7a2534d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 09:35:05 +0200 Subject: [PATCH 14/31] Implement ST-401 activity status flows --- app/planning/actions.ts | 49 ++++++++++++- .../planning/activity-status-actions.tsx | 68 +++++++++++++++++++ components/planning/today-activities-list.tsx | 50 +++++++++++++- docs/backlog/inspannings-monitor-backlog.md | 5 +- lib/feedback/status-messages.ts | 15 ++++ lib/planning/service.ts | 41 +++++++++++ lib/planning/types.ts | 5 ++ 7 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 components/planning/activity-status-actions.tsx diff --git a/app/planning/actions.ts b/app/planning/actions.ts index b2e8bab..1c535f2 100644 --- a/app/planning/actions.ts +++ b/app/planning/actions.ts @@ -5,9 +5,16 @@ import { buildPathWithQuery } from "@/lib/auth/navigation"; import { ACTIVITY_IMPACT_LEVEL_VALUES, ACTIVITY_PRIORITY_LEVEL_VALUES, + ACTIVITY_STATUS_VALUES, } from "@/lib/planning/options"; -import { createActivityForTodayForCurrentUser } from "@/lib/planning/service"; -import type { CreateActivitySubmission } from "@/lib/planning/types"; +import { + createActivityForTodayForCurrentUser, + updateActivityStatusForTodayForCurrentUser, +} from "@/lib/planning/service"; +import type { + CreateActivitySubmission, + UpdateActivityStatusSubmission, +} from "@/lib/planning/types"; import { assertMaxLength, FormDataValidationError, @@ -48,6 +55,20 @@ function buildCreateActivitySubmission(formData: FormData): CreateActivitySubmis }; } +function buildUpdateActivityStatusSubmission( + formData: FormData, +): UpdateActivityStatusSubmission { + return { + activityId: getUuidValue(formData, "activityId", "invalid-activity-status"), + status: getEnumValue( + formData, + "status", + ACTIVITY_STATUS_VALUES, + "invalid-activity-status", + ), + }; +} + export async function createActivityAction( _previousState: null, formData: FormData, @@ -69,3 +90,27 @@ export async function createActivityAction( redirect(buildPathWithQuery("/planning", { status: "activity-saved" })); return null; } + +export async function updateActivityStatusAction( + _previousState: null, + formData: FormData, +): Promise { + 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; +} diff --git a/components/planning/activity-status-actions.tsx b/components/planning/activity-status-actions.tsx new file mode 100644 index 0000000..cb4869f --- /dev/null +++ b/components/planning/activity-status-actions.tsx @@ -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: "Geschipt" }, + { value: "adjusted", label: "Aangepast" }, +]; + +export function ActivityStatusActions({ + activityId, + status, +}: ActivityStatusActionsProps) { + const [, formAction, isPending] = useActionState(updateActivityStatusAction, null); + + return ( +
+ +
+ {statusOptions.map((option) => { + const isCurrent = option.value === status; + + return ( + + ); + })} +
+

+ {isPending + ? "Status wordt opgeslagen..." + : "Je kunt de status vandaag direct aanpassen zonder de activiteit te verwijderen."} +

+
+ ); +} diff --git a/components/planning/today-activities-list.tsx b/components/planning/today-activities-list.tsx index 8432c50..b09513a 100644 --- a/components/planning/today-activities-list.tsx +++ b/components/planning/today-activities-list.tsx @@ -5,8 +5,10 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { ActivityStatusActions } from "@/components/planning/activity-status-actions"; import { deriveActivityEnergyPoints } from "@/lib/planning/meter"; import type { ActivityCategory, ActivityRecord } from "@/lib/planning/types"; +import { cn } from "@/lib/utils"; type TodayActivitiesListProps = { activities: ActivityRecord[]; @@ -41,6 +43,38 @@ function formatPriorityLabel(value: ActivityRecord["priorityLevel"]) { 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"; +} + export function TodayActivitiesList({ activities, categories, @@ -75,8 +109,13 @@ export function TodayActivitiesList({ {getCategoryLabel(categories, activity.categoryId)}

- - Gepland + + {formatStatusLabel(activity.status)} @@ -95,6 +134,13 @@ export function TodayActivitiesList({ {deriveActivityEnergyPoints(activity)}

+ +
+ +
)) )} diff --git a/docs/backlog/inspannings-monitor-backlog.md b/docs/backlog/inspannings-monitor-backlog.md index 34a704e..1631caf 100644 --- a/docs/backlog/inspannings-monitor-backlog.md +++ b/docs/backlog/inspannings-monitor-backlog.md @@ -92,9 +92,12 @@ Status: `ST-301`, `ST-302`, `ST-304` en `ST-305` zijn inmiddels gerealiseerd in Doel: de kernloop afronden door geplande activiteiten te evalueren en terug te zien. +Status: `ST-401` is inmiddels gerealiseerd in de app. De volgende logische stap +ligt nu in `ST-402` en `ST-403`. + | 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-403 | Ongeplande activiteiten ondersteunen | Build | Ongeplande activiteit telt mee in werkelijke totalen | | ST-404 | Dagoverzicht bouwen | UI | Gepland versus uitgevoerd en statusverdeling zijn zichtbaar | diff --git a/lib/feedback/status-messages.ts b/lib/feedback/status-messages.ts index 4afedf3..686c686 100644 --- a/lib/feedback/status-messages.ts +++ b/lib/feedback/status-messages.ts @@ -69,6 +69,11 @@ const planningStatusToasts: Record = { title: "Activiteit gepland", message: "Je activiteit staat nu in je dagplanning van vandaag.", }, + "activity-status-saved": { + variant: "success", + title: "Activiteit bijgewerkt", + message: "De status van je activiteit is opgeslagen.", + }, }; const planningErrorToasts: Record = { @@ -78,6 +83,16 @@ const planningErrorToasts: Record = { message: "Controleer naam, categorie, duur, impact en prioriteit en probeer het opnieuw.", }, + "invalid-activity-status": { + variant: "error", + title: "Status niet opgeslagen", + message: "De gekozen activiteit of status is ongeldig voor vandaag.", + }, + "activity-status-failed": { + variant: "error", + title: "Status niet opgeslagen", + message: "De activiteitstatus kon niet worden bijgewerkt. Probeer het opnieuw.", + }, }; export function getDashboardStatusToast(status: string | null): StatusToast | null { diff --git a/lib/planning/service.ts b/lib/planning/service.ts index e5d0914..d146088 100644 --- a/lib/planning/service.ts +++ b/lib/planning/service.ts @@ -10,6 +10,7 @@ import type { ActivitiesForDateStatus, ActivityStatus, SkipReason, + UpdateActivityStatusSubmission, } from "@/lib/planning/types"; import { ensureProfileBundleForCurrentUser } from "@/lib/profile/service"; import { createClient } from "@/lib/supabase/server"; @@ -303,3 +304,43 @@ export async function createActivityForTodayForCurrentUser( return mapActivityRow(data); } + +export async function updateActivityStatusForTodayForCurrentUser( + submission: UpdateActivityStatusSubmission, +): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + throw new Error("Er is geen ingelogde gebruiker beschikbaar."); + } + + const profileBundle = await ensureProfileBundleForCurrentUser(); + + if (!profileBundle) { + throw new Error("Profielbundle ontbreekt voor de huidige gebruiker."); + } + + const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone); + const supabase = await createClient(); + + const { data, error } = await supabase + .from("activities") + .update({ + status: submission.status, + }) + .eq("id", submission.activityId) + .eq("user_id", user.id) + .eq("activity_date", activityDate) + .select(ACTIVITY_COLUMNS) + .maybeSingle(); + + if (error) { + throw new Error(`Activiteitstatus kon niet worden opgeslagen: ${error.message}`); + } + + if (!data) { + throw new Error("Ongeldige of niet-beschikbare activiteit."); + } + + return mapActivityRow(data); +} diff --git a/lib/planning/types.ts b/lib/planning/types.ts index 1cf83a3..9bbd3a4 100644 --- a/lib/planning/types.ts +++ b/lib/planning/types.ts @@ -53,6 +53,11 @@ export type CreateActivitySubmission = { priorityLevel: ActivityPriorityLevel; }; +export type UpdateActivityStatusSubmission = { + activityId: string; + status: ActivityStatus; +}; + export type ActivitiesForDateStatus = { timezone: string; activityDate: string; From c3cd1de64752ab8adb94352abac1ff1b6b4bfc11 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 09:44:23 +0200 Subject: [PATCH 15/31] Fix theme initialization and hydration issues --- app/layout.tsx | 3 +- components/navigation/theme-menu.tsx | 16 +-- components/theme-provider.tsx | 203 ++++++++++++++++++++++++++- components/ui/sonner.tsx | 4 +- 4 files changed, 204 insertions(+), 22 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 20bef26..cfa32ae 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -32,11 +32,10 @@ export default function RootLayout({ ; - } - - if (theme === "dark") { - return ; - } - - return ; -} - export function ThemeMenu() { const { theme, setTheme } = useTheme(); return ( - {getThemeIcon(theme)} + Theme diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx index 2e0ad9b..831f265 100644 --- a/components/theme-provider.tsx +++ b/components/theme-provider.tsx @@ -1,10 +1,205 @@ "use client"; import * as React from "react"; -import { ThemeProvider as NextThemesProvider } from "next-themes"; -type ThemeProviderProps = React.ComponentProps; +const STORAGE_KEY = "inspannings-monitor-theme"; -export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return {children}; +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(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(() => + typeof window === "undefined" + ? defaultTheme + : readStoredTheme(defaultTheme, enableSystem), + ); + const [systemTheme, setSystemTheme] = + React.useState(initialSystemTheme); + const [resolvedTheme, setResolvedTheme] = + React.useState(() => { + 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( + () => ({ + theme, + setTheme, + resolvedTheme, + systemTheme, + themes: enableSystem ? AVAILABLE_THEMES : ["light", "dark"], + }), + [enableSystem, resolvedTheme, setTheme, systemTheme, theme], + ); + + return ( + {children} + ); +} + +export function useTheme() { + const context = React.useContext(ThemeContext); + + if (!context) { + throw new Error("useTheme must be used within ThemeProvider"); + } + + return context; } diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx index f0ebcd7..30bf937 100644 --- a/components/ui/sonner.tsx +++ b/components/ui/sonner.tsx @@ -7,7 +7,7 @@ import { OctagonXIcon, TriangleAlertIcon, } from "lucide-react"; -import { useTheme } from "next-themes"; +import { useTheme } from "@/components/theme-provider"; import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { @@ -15,7 +15,7 @@ const Toaster = ({ ...props }: ToasterProps) => { return ( , From 57ade6a7724cc8484745cba27921d4dbcbd8cd7c Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 09:51:20 +0200 Subject: [PATCH 16/31] Implement ST-402 activity evaluation fields --- app/planning/actions.ts | 52 +++++++ app/planning/page.tsx | 1 + .../planning/activity-evaluation-fields.tsx | 127 ++++++++++++++++++ .../planning/activity-status-actions.tsx | 2 +- components/planning/today-activities-list.tsx | 22 ++- docs/backlog/inspannings-monitor-backlog.md | 6 +- lib/feedback/status-messages.ts | 16 +++ lib/forms/parse.ts | 18 +++ lib/planning/service.ts | 99 +++++++++++++- lib/planning/types.ts | 7 + 10 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 components/planning/activity-evaluation-fields.tsx diff --git a/app/planning/actions.ts b/app/planning/actions.ts index 1c535f2..bdff837 100644 --- a/app/planning/actions.ts +++ b/app/planning/actions.ts @@ -9,10 +9,12 @@ import { } from "@/lib/planning/options"; import { createActivityForTodayForCurrentUser, + updateActivityEvaluationForTodayForCurrentUser, updateActivityStatusForTodayForCurrentUser, } from "@/lib/planning/service"; import type { CreateActivitySubmission, + UpdateActivityEvaluationSubmission, UpdateActivityStatusSubmission, } from "@/lib/planning/types"; import { @@ -20,6 +22,8 @@ import { FormDataValidationError, getEnumValue, getIntegerValue, + getOptionalString, + getOptionalUuidValue, getRequiredString, getUuidValue, } from "@/lib/forms/parse"; @@ -69,6 +73,24 @@ function buildUpdateActivityStatusSubmission( }; } +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, @@ -114,3 +136,33 @@ export async function updateActivityStatusAction( redirect(buildPathWithQuery("/planning", { status: "activity-status-saved" })); return null; } + +export async function saveActivityEvaluationAction( + _previousState: null, + formData: FormData, +): Promise { + 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; +} diff --git a/app/planning/page.tsx b/app/planning/page.tsx index ac02874..7ac474d 100644 --- a/app/planning/page.tsx +++ b/app/planning/page.tsx @@ -144,6 +144,7 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps) diff --git a/components/planning/activity-evaluation-fields.tsx b/components/planning/activity-evaluation-fields.tsx new file mode 100644 index 0000000..87dcde1 --- /dev/null +++ b/components/planning/activity-evaluation-fields.tsx @@ -0,0 +1,127 @@ +"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 ?? ""); + + if (status !== "skipped" && status !== "adjusted") { + return null; + } + + return ( +
+ + {status === "skipped" ? ( + <> + +
+ + +
+ +
+ +