From 5c15620993863c5d9d5301fb9225eb0689ba0cff Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 02:19:34 +0200 Subject: [PATCH] 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[]; +};